diff --git a/package.json b/package.json index acac5a13..01ecbb25 100644 --- a/package.json +++ b/package.json @@ -115,12 +115,14 @@ "electron-unhandled": "^4.0.1", "electron-updater": "^5.3.0", "filenamify": "^4.3.0", + "howler": "^2.2.3", "html-to-text": "^9.0.3", "md5": "^2.3.0", "mpris-service": "^2.1.2", "node-fetch": "^2.6.8", "node-notifier": "^10.0.1", "vudio": "^2.1.1", + "youtubei.js": "^2.9.0", "ytdl-core": "^4.11.1", "ytpl": "^2.3.0" }, diff --git a/plugins/crossfade/back.js b/plugins/crossfade/back.js new file mode 100644 index 00000000..ee2dc679 --- /dev/null +++ b/plugins/crossfade/back.js @@ -0,0 +1,13 @@ +const { ipcMain } = require("electron"); +const { Innertube } = require("youtubei.js"); + +module.exports = async (win, options) => { + const yt = await Innertube.create(); + + ipcMain.handle("audio-url", async (_, videoID) => { + const info = await yt.getBasicInfo(videoID); + const url = info.streaming_data?.formats[0].decipher(yt.session.player); + + return url; + }); +}; diff --git a/plugins/crossfade/fader.js b/plugins/crossfade/fader.js new file mode 100644 index 00000000..410bf7ad --- /dev/null +++ b/plugins/crossfade/fader.js @@ -0,0 +1,360 @@ +/** + * VolumeFader + * Sophisticated Media Volume Fading + * + * Requires browser support for: + * - HTMLMediaElement + * - requestAnimationFrame() + * - ES6 + * + * Does not depend on any third-party library. + * + * License: MIT + * + * Nick Schwarzenberg + * v0.2.0, 07/2016 + */ + +(function (root) { + "use strict"; + + // internal utility: check if value is a valid volume level and throw if not + let validateVolumeLevel = (value) => { + // number between 0 and 1? + if (!Number.isNaN(value) && value >= 0 && value <= 1) { + // yup, that's fine + return; + } else { + // abort and throw an exception + throw new TypeError("Number between 0 and 1 expected as volume!"); + } + }; + + // main class + class VolumeFader { + /** + * VolumeFader Constructor + * + * @param media {HTMLMediaElement} - audio or video element to be controlled + * @param options {Object} - an object with optional settings + * @throws {TypeError} if options.initialVolume or options.fadeDuration are invalid + * + * options: + * .logger: {Function} logging `function(stuff, …)` for execution information (default: no logging) + * .fadeScaling: {Mixed} either 'linear', 'logarithmic' or a positive number in dB (default: logarithmic) + * .initialVolume: {Number} media volume 0…1 to apply during setup (volume not touched by default) + * .fadeDuration: {Number} time in milliseconds to complete a fade (default: 1000 ms) + */ + constructor(media, options) { + // passed media element of correct type? + if (media instanceof HTMLMediaElement) { + // save reference to media element + this.media = media; + } else { + // abort and throw an exception + throw new TypeError("Media element expected!"); + } + + // make sure options is an object + options = options || {}; + + // log function passed? + if (typeof options.logger == "function") { + // set log function to the one specified + this.logger = options.logger; + } else { + // set log function explicitly to false + this.logger = false; + } + + // linear volume fading? + if (options.fadeScaling == "linear") { + // pass levels unchanged + this.scale = { + internalToVolume: (level) => level, + volumeToInternal: (level) => level, + }; + + // log setting + this.logger && this.logger("Using linear fading."); + } + // no linear, but logarithmic fading… + else { + let dynamicRange; + + // default dynamic range? + if ( + options.fadeScaling === undefined || + options.fadeScaling == "logarithmic" + ) { + // set default of 60 dB + dynamicRange = 3; + } + // custom dynamic range? + else if ( + !Number.isNaN(options.fadeScaling) && + options.fadeScaling > 0 + ) { + // turn amplitude dB into a multiple of 10 power dB + dynamicRange = options.fadeScaling / 2 / 10; + } + // unsupported value + else { + // abort and throw exception + throw new TypeError( + "Expected 'linear', 'logarithmic' or a positive number as fade scaling preference!" + ); + } + + // use exponential/logarithmic scaler for expansion/compression + this.scale = { + internalToVolume: (level) => + this.exponentialScaler(level, dynamicRange), + volumeToInternal: (level) => + this.logarithmicScaler(level, dynamicRange), + }; + + // log setting if not default + options.fadeScaling && + this.logger && + this.logger( + "Using logarithmic fading with " + + String(10 * dynamicRange) + + " dB dynamic range." + ); + } + + // set initial volume? + if (options.initialVolume !== undefined) { + // validate volume level and throw if invalid + validateVolumeLevel(options.initialVolume); + + // set initial volume + this.media.volume = options.initialVolume; + + // log setting + this.logger && + this.logger( + "Set initial volume to " + String(this.media.volume) + "." + ); + } + + // fade duration given? + if (options.fadeDuration !== undefined) { + // try to set given fade duration (will log if successful and throw if not) + this.setFadeDuration(options.fadeDuration); + } else { + // set default fade duration (1000 ms) + this.fadeDuration = 1000; + } + + // indicate that fader is not active yet + this.active = false; + + // initialization done + this.logger && this.logger("Initialized for", this.media); + } + + /** + * Re(start) the update cycle. + * (this.active must be truthy for volume updates to take effect) + * + * @return {Object} VolumeFader instance for chaining + */ + start() { + // set fader to be active + this.active = true; + + // start by running the update method + this.updateVolume(); + + // return instance for chaining + return this; + } + + /** + * Stop the update cycle. + * (interrupting any fade) + * + * @return {Object} VolumeFader instance for chaining + */ + stop() { + // set fader to be inactive + this.active = false; + + // return instance for chaining + return this; + } + + /** + * Set fade duration. + * (used for future calls to fadeTo) + * + * @param {Number} fadeDuration - fading length in milliseconds + * @throws {TypeError} if fadeDuration is not a number greater than zero + * @return {Object} VolumeFader instance for chaining + */ + setFadeDuration(fadeDuration) { + // if duration is a valid number > 0… + if (!Number.isNaN(fadeDuration) && fadeDuration > 0) { + // set fade duration + this.fadeDuration = fadeDuration; + + // log setting + this.logger && + this.logger("Set fade duration to " + String(fadeDuration) + " ms."); + } else { + // abort and throw an exception + throw new TypeError("Positive number expected as fade duration!"); + } + + // return instance for chaining + return this; + } + + /** + * Define a new fade and start fading. + * + * @param {Number} targetVolume - level to fade to in the range 0…1 + * @param {Function} callback - (optional) function to be called when fade is complete + * @throws {TypeError} if targetVolume is not in the range 0…1 + * @return {Object} VolumeFader instance for chaining + */ + fadeTo(targetVolume, callback) { + // validate volume and throw if invalid + validateVolumeLevel(targetVolume); + + // define new fade + this.fade = { + // volume start and end point on internal fading scale + volume: { + start: this.scale.volumeToInternal(this.media.volume), + end: this.scale.volumeToInternal(targetVolume), + }, + // time start and end point + time: { + start: Date.now(), + end: Date.now() + this.fadeDuration, + }, + // optional callback function + callback: callback, + }; + + // start fading + this.start(); + + // log new fade + this.logger && this.logger("New fade started:", this.fade); + + // return instance for chaining + return this; + } + + // convenience shorthand methods for common fades + fadeIn(callback) { + this.fadeTo(1, callback); + } + fadeOut(callback) { + this.fadeTo(0, callback); + } + + /** + * Internal: Update media volume. + * (calls itself through requestAnimationFrame) + * + * @param {Number} targetVolume - linear level to fade to (0…1) + * @param {Function} callback - (optional) function to be called when fade is complete + */ + updateVolume() { + // fader active and fade available to process? + if (this.active && this.fade) { + // get current time + let now = Date.now(); + + // time left for fading? + if (now < this.fade.time.end) { + // compute current fade progress + let progress = + (now - this.fade.time.start) / + (this.fade.time.end - this.fade.time.start); + + // compute current level on internal scale + let level = + progress * (this.fade.volume.end - this.fade.volume.start) + + this.fade.volume.start; + + // map fade level to volume level and apply it to media element + this.media.volume = this.scale.internalToVolume(level); + + // schedule next update + root.requestAnimationFrame(this.updateVolume.bind(this)); + } else { + // log end of fade + this.logger && + this.logger( + "Fade to " + String(this.fade.volume.end) + " complete." + ); + + // time is up, jump to target volume + this.media.volume = this.scale.internalToVolume(this.fade.volume.end); + + // set fader to be inactive + this.active = false; + + // done, call back (if callable) + typeof this.fade.callback == "function" && this.fade.callback(); + + // clear fade + this.fade = undefined; + } + } + } + + /** + * Internal: Exponential scaler with dynamic range limit. + * + * @param {Number} input - logarithmic input level to be expanded (float, 0…1) + * @param {Number} dynamicRange - expanded output range, in multiples of 10 dB (float, 0…∞) + * @return {Number} - expanded level (float, 0…1) + */ + exponentialScaler(input, dynamicRange) { + // special case: make zero (or any falsy input) return zero + if (input == 0) { + // since the dynamic range is limited, + // allow a zero to produce a plain zero instead of a small faction + // (audio would not be recognized as silent otherwise) + return 0; + } else { + // scale 0…1 to minus something × 10 dB + input = (input - 1) * dynamicRange; + + // compute power of 10 + return Math.pow(10, input); + } + } + + /** + * Internal: Logarithmic scaler with dynamic range limit. + * + * @param {Number} input - exponential input level to be compressed (float, 0…1) + * @param {Number} dynamicRange - coerced input range, in multiples of 10 dB (float, 0…∞) + * @return {Number} - compressed level (float, 0…1) + */ + logarithmicScaler(input, dynamicRange) { + // special case: make zero (or any falsy input) return zero + if (input == 0) { + // logarithm of zero would be -∞, which would map to zero anyway + return 0; + } else { + // compute base-10 logarithm + input = Math.log10(input); + + // scale minus something × 10 dB to 0…1 (clipping at 0) + return Math.max(1 + input / dynamicRange, 0); + } + } + } + + // export class to root scope + root.VolumeFader = VolumeFader; +})(window); diff --git a/plugins/crossfade/front.js b/plugins/crossfade/front.js new file mode 100644 index 00000000..433f5c19 --- /dev/null +++ b/plugins/crossfade/front.js @@ -0,0 +1,152 @@ +const { ipcRenderer } = require("electron"); +const { Howl } = require("howler"); + +// Extracted from https://github.com/bitfasching/VolumeFader +require("./fader"); + +let transitionAudio; // Howler audio used to fade out the current music +let firstVideo = true; +let transitioning = false; + +// Crossfade options that can be overridden in plugin options +let crossfadeOptions = { + fadeInDuration: 1500, // ms + fadeOutDuration: 5000, // ms + exitMusicBeforeEnd: 10, // s + fadeScaling: "linear", +}; + +const getStreamURL = async (videoID) => { + const url = await ipcRenderer.invoke("audio-url", videoID); + return url; +}; + +const getVideoIDFromURL = (url) => { + return new URLSearchParams(url.split("?")?.at(-1)).get("v"); +}; + +const isReadyToCrossfade = () => { + return transitionAudio && transitionAudio.state() === "loaded"; +}; + +const watchVideoIDChanges = (cb) => { + navigation.addEventListener("navigate", (event) => { + const currentVideoID = getVideoIDFromURL( + event.currentTarget.currentEntry.url + ); + const nextVideoID = getVideoIDFromURL(event.destination.url); + + if ( + nextVideoID && + currentVideoID && + (firstVideo || nextVideoID !== currentVideoID) + ) { + if (isReadyToCrossfade()) { + crossfade(() => { + cb(nextVideoID); + }); + } else { + cb(nextVideoID); + firstVideo = false; + } + } + }); +}; + +const createAudioForCrossfade = async (url) => { + if (transitionAudio) { + transitionAudio.unload(); + } + transitionAudio = new Howl({ + src: url, + html5: true, + volume: 0, + }); + await syncVideoWithTransitionAudio(); +}; + +const syncVideoWithTransitionAudio = async () => { + const video = document.querySelector("video"); + const videoFader = new VolumeFader(video, { + fadeScaling: crossfadeOptions.fadeScaling, + fadeDuration: crossfadeOptions.fadeInDuration, + }); + + await transitionAudio.play(); + await transitionAudio.seek(video.currentTime); + + video.onseeking = () => { + transitionAudio.seek(video.currentTime); + }; + video.onpause = () => { + transitionAudio.pause(); + }; + video.onplay = async () => { + await transitionAudio.play(); + await transitionAudio.seek(video.currentTime); + + // Fade in + const videoVolume = video.volume; + video.volume = 0; + videoFader.fadeTo(videoVolume); + }; + + // Exit just before the end for the transition + const transitionBeforeEnd = () => { + if ( + video.currentTime >= + video.duration - crossfadeOptions.exitMusicBeforeEnd && + isReadyToCrossfade() + ) { + video.removeEventListener("timeupdate", transitionBeforeEnd); + + // Go to next video - XXX: does not support "repeat 1" mode + document.querySelector(".next-button").click(); + } + }; + video.ontimeupdate = transitionBeforeEnd; +}; + +const onApiLoaded = () => { + watchVideoIDChanges(async (videoID) => { + if (!transitioning) { + const url = await getStreamURL(videoID); + await createAudioForCrossfade(url); + } + }); +}; + +const crossfade = (cb) => { + if (!isReadyToCrossfade()) { + cb(); + return; + } + transitioning = true; + + const video = document.querySelector("video"); + + const fader = new VolumeFader(transitionAudio._sounds[0]._node, { + initialVolume: video.volume, + fadeScaling: crossfadeOptions.fadeScaling, + fadeDuration: crossfadeOptions.fadeOutDuration, + }); + + // Fade out the music + video.volume = 0; + fader.fadeOut(() => { + transitioning = false; + cb(); + }); +}; + +module.exports = (options) => { + crossfadeOptions = { + ...crossfadeOptions, + options, + }; + + document.addEventListener("apiLoaded", onApiLoaded, { + once: true, + passive: true, + }); +}; diff --git a/readme.md b/readme.md index 1a410bd1..bf086f7f 100644 --- a/readme.md +++ b/readme.md @@ -66,6 +66,8 @@ winget install th-ch.YouTubeMusic - **Captions selector**: Enable captions +- **Crossfade**: Crossfade between songs + - **Disable Autoplay**: Makes every song start in "paused" mode - [**Discord**](https://discord.com/): Show your friends what you listen to with [Rich Presence](https://user-images.githubusercontent.com/28219076/104362104-a7a0b980-5513-11eb-9744-bb89eabe0016.png) diff --git a/yarn.lock b/yarn.lock index d512fc27..4712bdeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -541,6 +541,11 @@ "@types/node" "*" playwright-core "1.29.2" +"@protobuf-ts/runtime@^2.7.0": + version "2.8.2" + resolved "https://registry.yarnpkg.com/@protobuf-ts/runtime/-/runtime-2.8.2.tgz#5d5424a6ae7fb846c3f4d0f2dd6448db65bb69d6" + integrity sha512-PVxsH81y9kEbHldxxG/8Y3z2mTXWQytRl8zNS0mTPUjkEC+8GUX6gj6LsA8EFp25fAs9V0ruh+aNWmPccEI9MA== + "@remusao/guess-url-type@^1.1.2": version "1.2.1" resolved "https://registry.yarnpkg.com/@remusao/guess-url-type/-/guess-url-type-1.2.1.tgz#b3e7c32abdf98d0fb4f93cc67cad580b5fe4ba57" @@ -1265,6 +1270,11 @@ bmp-js@^0.1.0: resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233" integrity sha1-4Fpj95amwf8l9Hcex62twUjAcjM= +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + boolean@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.0.3.tgz#0fee0c9813b66bef25a8a6a904bb46736d05f024" @@ -1386,6 +1396,13 @@ builtins@^5.0.1: dependencies: semver "^7.0.0" +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + butterchurn-presets@^2.4.7: version "2.4.7" resolved "https://registry.yarnpkg.com/butterchurn-presets/-/butterchurn-presets-2.4.7.tgz#41e4e37cd3af2aec124fa8062352816100956c29" @@ -1753,6 +1770,27 @@ crypt@0.0.2: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + custom-electron-prompt@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/custom-electron-prompt/-/custom-electron-prompt-1.5.1.tgz#24a63a7829c2ebcd2d898a312f9dff65785c2da7" @@ -3464,6 +3502,16 @@ hosted-git-info@^5.0.0: dependencies: lru-cache "^7.5.1" +howler@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/howler/-/howler-2.2.3.tgz#a2eff9b08b586798e7a2ee17a602a90df28715da" + integrity sha512-QM0FFkw0LRX1PR8pNzJVAY25JhIWvbKMBFM4gqk+QdV+kPXOhleWGCB6AiAF/goGjIHK2e/nIElplvjQwhr0jg== + +html-escaper@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-3.0.3.tgz#4d336674652beb1dcbc29ef6b6ba7f6be6fdfed6" + integrity sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ== + html-to-text@^9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.3.tgz#331368f32fcb270c59dbd3a7fdb32813d2a490bc" @@ -4089,6 +4137,13 @@ jimp@^0.14.0: "@jimp/types" "^0.14.0" regenerator-runtime "^0.13.3" +jintr@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/jintr/-/jintr-0.3.1.tgz#0ab49390a187d77dc5f2c19580c644d70a94528a" + integrity sha512-AUcq8fKL4BE9jDx8TizZmJ9UOvk1CHKFW0nQcWaOaqk9tkLS9S10fNmusTWGEYTncn7U43nXrCbhYko/ylqrSg== + dependencies: + acorn "^8.8.0" + jpeg-js@^0.4.0: version "0.4.4" resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.4.tgz#a9f1c6f1f9f0fa80cdb3484ed9635054d28936aa" @@ -4311,6 +4366,17 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= +linkedom@^0.14.12: + version "0.14.21" + resolved "https://registry.yarnpkg.com/linkedom/-/linkedom-0.14.21.tgz#878e1e5e88028cb1d57bc6262f84484a41a37497" + integrity sha512-V+c0AAFMTVJA2iAhrdd+u44lL0TjL6hBenVB061VQ6BHqTAHtXw1v5F1/CHGKtwg0OHm+hrGbepb9ZSFJ7lJkg== + dependencies: + css-select "^5.1.0" + cssom "^0.5.0" + html-escaper "^3.0.3" + htmlparser2 "^8.0.1" + uhyphen "^0.1.0" + load-bmfont@^1.3.1, load-bmfont@^1.4.0: version "1.4.1" resolved "https://registry.yarnpkg.com/load-bmfont/-/load-bmfont-1.4.1.tgz#c0f5f4711a1e2ccff725a7b6078087ccfcddd3e9" @@ -4790,6 +4856,13 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -5925,6 +5998,11 @@ stream-combiner@~0.0.4: dependencies: duplexer "~0.1.1" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-similarity@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-1.1.0.tgz#3c66498858a465ec7c40c7d81739bbd995904914" @@ -6358,6 +6436,11 @@ uglify-js@^3.1.4: resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.15.1.tgz#9403dc6fa5695a6172a91bc983ea39f0f7c9086d" integrity sha512-FAGKF12fWdkpvNJZENacOH0e/83eG6JyVQyanIJaBXCN1J11TUQv1T1/z8S+Z0CG0ZPk1nPcreF/c7lrTd0TEQ== +uhyphen@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/uhyphen/-/uhyphen-0.1.0.tgz#3cc22afa790daa802b9f6789f3583108d5b4a08c" + integrity sha512-o0QVGuFg24FK765Qdd5kk0zU/U4dEsCtN/GSiwNI9i8xsSVtjIAOdTaVhLwZ1nrbWxFVMxNDDl+9fednsOMsBw== + unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" @@ -6383,6 +6466,13 @@ unc-path-regex@^0.1.2: resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= +undici@^5.7.0: + version "5.16.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.16.0.tgz#6b64f9b890de85489ac6332bd45ca67e4f7d9943" + integrity sha512-KWBOXNv6VX+oJQhchXieUznEmnJMqgXMbs0xxH2t8q/FUAWSJvOSr/rMaZKnX5RIVq7JDn0JbP4BOnKG2SGXLQ== + dependencies: + busboy "^1.6.0" + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -6727,6 +6817,16 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +youtubei.js@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-2.9.0.tgz#17426dfb0555169cddede509d50d3db62c102270" + integrity sha512-paxfeQGwxGw0oPeKdC96jNalS0OnYQ5xdJY27k3J+vamzVcwX6Ky+idALW6Ej9aUC7FISbchBsEVg0Wa7wgGyA== + dependencies: + "@protobuf-ts/runtime" "^2.7.0" + jintr "^0.3.1" + linkedom "^0.14.12" + undici "^5.7.0" + ytdl-core@^4.11.1: version "4.11.2" resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.11.2.tgz#c2341916b9757171741a2fa118b6ffbb4ffd92f7"