diff options
-rw-r--r-- | backend.js | 193 | ||||
-rw-r--r-- | general-util.js | 25 | ||||
-rwxr-xr-x | index.js | 45 | ||||
-rw-r--r-- | package-lock.json | 1084 | ||||
-rw-r--r-- | package.json | 4 | ||||
-rw-r--r-- | players.js | 15 | ||||
-rw-r--r-- | playlist-utils.js | 324 | ||||
-rw-r--r-- | serialized-backend.js | 228 | ||||
-rw-r--r-- | socket.js | 992 | ||||
-rw-r--r-- | todo.txt | 42 | ||||
-rw-r--r-- | ui.js | 293 |
11 files changed, 2101 insertions, 1144 deletions
diff --git a/backend.js b/backend.js index 4142026..6576868 100644 --- a/backend.js +++ b/backend.js @@ -7,6 +7,8 @@ import {readFile, writeFile} from 'node:fs/promises' import EventEmitter from 'node:events' import os from 'node:os' +import shortid from 'shortid' + import {getDownloaderFor} from './downloaders.js' import {getMetadataReaderFor} from './metadata-readers.js' import {getPlayer} from './players.js' @@ -58,6 +60,8 @@ class QueuePlayer extends EventEmitter { }) { super() + this.id = shortid.generate() + this.player = null this.playingTrack = null this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []} @@ -65,6 +69,10 @@ class QueuePlayer extends EventEmitter { this.queueEndMode = 'end' // end, loop, shuffle this.playedTrackToEnd = false this.timeData = null + this.time = null + + this.alwaysStartPaused = false + this.waitWhenDonePlaying = false this.getPlayer = getPlayer this.getRecordFor = getRecordFor @@ -83,7 +91,8 @@ class QueuePlayer extends EventEmitter { if (this.playingTrack) { const oldTimeData = this.timeData this.timeData = data - this.emit('received time data', data, oldTimeData, this) + this.time = data.curSecTotal + this.emit('received time data', data, oldTimeData) } }) @@ -158,6 +167,7 @@ class QueuePlayer extends EventEmitter { } recursivelyAddTracks(topItem) + this.emit('queue', topItem, afterItem, {movePlayingTrack}) this.emitQueueUpdated() // This is the first new track, if a group was queued. @@ -166,9 +176,12 @@ class QueuePlayer extends EventEmitter { return newTrack } - distributeQueue(grouplike, {how = 'evenly', rangeEnd = 'end-of-queue'}) { - if (isTrack(grouplike)) { - grouplike = {items: [grouplike]} + distributeQueue(topItem, {how = 'evenly', rangeEnd = 'end-of-queue'} = {}) { + let grouplike + if (isTrack(topItem)) { + grouplike = {items: [topItem]} + } else { + grouplike = topItem } const { items } = this.queueGrouplike @@ -220,6 +233,7 @@ class QueuePlayer extends EventEmitter { } } + this.emit('distribute queue', topItem, {how, rangeEnd}) this.emitQueueUpdated() } @@ -264,11 +278,17 @@ class QueuePlayer extends EventEmitter { } recursivelyUnqueueTracks(topItem) + this.emit('unqueue', topItem) this.emitQueueUpdated() return focusItem } + replaceAllItems(newItems) { + this.queueGrouplike.items = newItems + this.emitQueueUpdated() + } + clearQueuePast(track) { const { items } = this.queueGrouplike const index = items.indexOf(track) + 1 @@ -281,6 +301,7 @@ class QueuePlayer extends EventEmitter { items.splice(index) } + this.emit('clear queue past', track) this.emitQueueUpdated() } @@ -297,6 +318,7 @@ class QueuePlayer extends EventEmitter { items.splice(startIndex, endIndex - startIndex) } + this.emit('clear queue up to', track) this.emitQueueUpdated() } @@ -329,6 +351,7 @@ class QueuePlayer extends EventEmitter { const remainingItems = queue.items.slice(index) const newItems = initialItems.concat(shuffleArray(remainingItems)) queue.items = newItems + this.emit('shuffle queue') this.emitQueueUpdated() } @@ -337,6 +360,7 @@ class QueuePlayer extends EventEmitter { // the track that's currently playing). this.queueGrouplike.items = this.queueGrouplike.items .filter(item => item === this.playingTrack) + this.emit('clear queue') this.emitQueueUpdated() } @@ -352,8 +376,7 @@ class QueuePlayer extends EventEmitter { this.clearPlayingTrack() } - - async play(item, startTime = 0) { + async play(item, startTime = 0, forceStartPaused = false) { if (this.player === null) { throw new Error('Attempted to play before a player was loaded') } @@ -399,11 +422,15 @@ class QueuePlayer extends EventEmitter { } this.timeData = null + this.time = null this.playingTrack = item - this.emit('playing', this.playingTrack, oldTrack, startTime, this) + this.emit('playing details', this.playingTrack, oldTrack, startTime) + this.emit('playing', this.playingTrack) await this.player.kill() - if (this.playedTrackToEnd) { + if (this.alwaysStartPaused || forceStartPaused) { + this.player.setPause(true) + } else if (this.playedTrackToEnd) { this.player.setPause(this.pauseNextTrack) this.pauseNextTrack = false this.playedTrackToEnd = false @@ -418,7 +445,10 @@ class QueuePlayer extends EventEmitter { if (playingThisTrack) { this.playedTrackToEnd = true - this.playNext(item) + this.emit('done playing', this.playingTrack) + if (!this.waitWhenDonePlaying) { + this.playNext(item) + } } } @@ -537,7 +567,9 @@ class QueuePlayer extends EventEmitter { const oldTrack = this.playingTrack this.playingTrack = null this.timeData = null - this.emit('playing', null, oldTrack, 0, this) + this.time = null + this.emit('playing details', null, oldTrack, 0) + this.emit('playing', null) } } @@ -546,11 +578,25 @@ class QueuePlayer extends EventEmitter { } seekAhead(seconds) { + this.time += seconds this.player.seekAhead(seconds) + this.emit('seek ahead', +seconds) } seekBack(seconds) { + if (this.time < seconds) { + this.time = 0 + } else { + this.time -= seconds + } this.player.seekBack(seconds) + this.emit('seek back', +seconds) + } + + seekTo(timeInSecs) { + this.time = timeInSecs + this.player.seekTo(timeInSecs) + this.emit('seek to', +timeInSecs) } seekTo(seconds) { @@ -563,47 +609,55 @@ class QueuePlayer extends EventEmitter { togglePause() { this.player.togglePause() + this.emit('toggle pause') } setPause(value) { this.player.setPause(value) + this.emit('set pause', !!value) } toggleLoop() { this.player.toggleLoop() + this.emit('toggle loop') } setLoop(value) { this.player.setLoop(value) + this.emit('set loop', !!value) } - volUp(amount = 10) { + volumeUp(amount = 10) { this.player.volUp(amount) + this.emit('volume up', +amount) } - volDown(amount = 10) { + volumeDown(amount = 10) { this.player.volDown(amount) + this.emit('volume down', +amount) } setVolume(value) { this.player.setVolume(value) + this.emit('set volume', +value) } setVolumeMultiplier(value) { - this.player.setVolumeMultiplier(value); + this.player.setVolumeMultiplier(value) } fadeIn() { - return this.player.fadeIn(); + return this.player.fadeIn() } setPauseNextTrack(value) { this.pauseNextTrack = !!value + this.emit('set pause next track', !!value) } setLoopQueueAtEnd(value) { this.loopQueueAtEnd = !!value - this.emit('set-loop-queue-at-end', !!value) + this.emit('set loop queue at end', !!value) } get remainingTracks() { @@ -636,14 +690,24 @@ export default class Backend extends EventEmitter { } = {}) { super() - this.playerName = playerName; - this.playerOptions = playerOptions; + this.playerName = playerName + this.playerOptions = playerOptions if (playerOptions.length && !playerName) { throw new Error(`Must specify playerName to specify playerOptions`); } this.queuePlayers = [] + this.alwaysStartPaused = false + this.waitWhenDonePlaying = false + + this.hasAnnouncedJoin = false + this.sharedSourcesMap = Object.create(null) + this.sharedSourcesGrouplike = { + name: 'Shared Sources', + isPartySources: true, + items: [] + } this.recordStore = new RecordStore() this.throttleMetadata = throttlePromise(10) @@ -675,34 +739,40 @@ export default class Backend extends EventEmitter { return error } + queuePlayer.alwaysStartPaused = this.alwaysStartPaused + queuePlayer.waitWhenDonePlaying = this.waitWhenDonePlaying + this.queuePlayers.push(queuePlayer) this.emit('added queue player', queuePlayer) for (const event of [ - 'playing', + 'clear queue', + 'clear queue past', + 'clear queue up to', + 'distribute queue', 'done playing', + 'playing', + 'playing details', 'queue', - 'distribute-queue', - 'unqueue', - 'clear-queue-past', - 'clear-queue-up-to', - 'shuffle-queue', - 'clear-queue', 'queue updated', - 'seek-ahead', - 'seek-back', - 'toggle-pause', - 'set-pause', - 'toggle-loop', - 'set-loop', - 'vol-up', - 'vol-down', - 'set-volume', - 'set-pause-next-track', - 'set-loop-queue-at-end' + 'received time data', + 'seek ahead', + 'seek back', + 'seek to', + 'set loop', + 'set loop queue at end', + 'set pause', + 'set pause next track', + 'set volume', + 'shuffle queue', + 'toggle loop', + 'toggle pause', + 'unqueue', + 'volume down', + 'volume up', ]) { queuePlayer.on(event, (...data) => { - this.emit(event, queuePlayer, ...data) + this.emit('QP: ' + event, queuePlayer, ...data) }) } @@ -802,6 +872,20 @@ export default class Backend extends EventEmitter { return {seconds, string, noticedMissingMetadata, approxSymbol} } + setAlwaysStartPaused(value) { + this.alwaysStartPaused = !!value + for (const queuePlayer of this.queuePlayers) { + queuePlayer.alwaysStartPaused = !!value + } + } + + setWaitWhenDonePlaying(value) { + this.waitWhenDonePlaying = !!value + for (const queuePlayer of this.queuePlayers) { + queuePlayer.waitWhenDonePlaying = !!value + } + } + async stopPlayingAll() { for (const queuePlayer of this.queuePlayers) { await queuePlayer.stopPlaying() @@ -811,4 +895,41 @@ export default class Backend extends EventEmitter { async download(item) { return download(item, this.getRecordFor(item)) } + + showLogMessage(messageInfo) { + this.emit('log message', messageInfo) + } + + setPartyNickname(nickname) { + this.emit('set party nickname', nickname) + } + + announceJoinParty() { + this.emit('announce join party') + } + + setHasAnnouncedJoin(hasAnnouncedJoin) { + this.hasAnnouncedJoin = hasAnnouncedJoin + } + + loadSharedSources(socketId, sharedSources) { + if (socketId in this.sharedSourcesMap) { + return + } + + this.sharedSourcesMap[socketId] = sharedSources + + sharedSources[parentSymbol] = this.sharedSourcesGrouplike + this.sharedSourcesGrouplike.items.push(sharedSources) + + this.emit('got shared sources', socketId, sharedSources) + } + + sharedSourcesUpdated(socketId, sharedSources) { + this.emit('shared sources updated', socketId, sharedSources) + } + + shareWithParty(item) { + this.emit('share with party', item) + } } diff --git a/general-util.js b/general-util.js index bb0574a..536b3fd 100644 --- a/general-util.js +++ b/general-util.js @@ -329,3 +329,28 @@ export async function parseOptions(options, optionDescriptorMap) { } parseOptions.handleDashless = Symbol() + +export async function silenceEvents(emitter, eventsToSilence, callback) { + const oldEmit = emitter.emit + + emitter.emit = function(event, ...data) { + if (!eventsToSilence.includes(event)) { + oldEmit.apply(emitter, [event, ...data]) + } + } + + await callback() + + emitter.emit = oldEmit +} + +// Kindly stolen from ESDiscuss: +// https://esdiscuss.org/topic/proposal-add-an-option-to-omit-prototype-of-objects-created-by-json-parse#content-1 +export function parseWithoutPrototype(string) { + return JSON.parse(string, function(k, v) { + if (v && typeof v === 'object' && !Array.isArray(v)) { + return Object.assign(Object.create(null), v) + } + return v + }) +} diff --git a/index.js b/index.js index 7632844..fea9222 100755 --- a/index.js +++ b/index.js @@ -9,6 +9,13 @@ import Backend from './backend.js' import setupClient from './client.js' import TelnetServer from './telnet.js' +import { + makeSocketServer, + makeSocketClient, + attachBackendToSocketClient, + attachSocketServerToBackend, +} from './socket.js' + import {CommandLineInterface} from 'tui-lib/util/interfaces' import * as ansi from 'tui-lib/util/ansi' @@ -52,6 +59,9 @@ async function main() { 'player-options': {type: 'series'}, 'stress-test': {type: 'flag'}, + 'socket-client': {type: 'value'}, + 'socket-name': {type: 'value'}, + 'socket-server': {type: 'value'}, 'telnet-server': {type: 'flag'}, 'skip-config-file': {type: 'flag'}, 'config-file': {type: 'value'}, @@ -113,7 +123,10 @@ async function main() { const { appElement, dirtyTerminal, flushable, root } = await setupClient({ backend, screenInterface: new CommandLineInterface(), - writable: process.stdout + writable: process.stdout, + appConfig: { + showPartyControls: !!(options['socket-server'] || options['socket-client']) + }, }) appElement.on('quitRequested', () => { @@ -164,6 +177,36 @@ async function main() { appElement.attachAsServerHost(telnetServer) } + let socketClient + let socketServer + if (options['socket-server']) { + socketServer = makeSocketServer() + attachSocketServerToBackend(socketServer, backend) + socketServer.listen(options['socket-server']) + + socketClient = makeSocketClient() + socketClient.socket.connect(options['socket-server']) + } + + if (options['socket-client']) { + socketClient = makeSocketClient() + const [ p1, p2 ] = options['socket-client'].split(':') + const host = p2 && p1 + const port = p2 ? p2 : p1 + socketClient.socket.connect(port, host) + } + + if (socketClient) { + attachBackendToSocketClient(backend, socketClient) + + let nickname = process.env.USER + if (options['socket-name']) { + nickname = options['socket-name'] + } + backend.setPartyNickname(nickname) + backend.announceJoinParty() + } + if (options['stress-test']) { await loadPlaylistPromise diff --git a/package-lock.json b/package-lock.json index 7870e28..25df086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,7 +1,7 @@ { "name": "mtui", "version": "0.0.1", - "lockfileVersion": 2, + "lockfileVersion": 3, "requires": true, "packages": { "": { @@ -14,9 +14,10 @@ "mkdirp": "^3.0.1", "natural-orderby": "^2.0.3", "node-fetch": "^2.6.0", - "open": "^7.0.3", + "open": "^7.0.4", "sanitize-filename": "^1.6.3", "shell-escape": "^0.2.0", + "shortid": "^2.2.15", "tempy": "^0.2.1", "tui-lib": "^0.4.0", "tui-text-editor": "^0.3.1" @@ -262,8 +263,7 @@ }, "node_modules/clone": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "license": "MIT", "engines": { "node": ">=0.8" } @@ -288,8 +288,7 @@ }, "node_modules/command-exists": { "version": "1.2.9", - "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", @@ -313,8 +312,7 @@ }, "node_modules/crypto-random-string": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", - "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=", + "license": "MIT", "engines": { "node": ">=4" } @@ -344,8 +342,7 @@ }, "node_modules/defaults": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", - "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "license": "MIT", "dependencies": { "clone": "^1.0.2" } @@ -520,8 +517,7 @@ }, "node_modules/expand-home-dir": { "version": "0.0.3", - "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz", - "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" + "license": "BSD" }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -716,17 +712,10 @@ "dev": true }, "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "bin": { - "is-docker": "cli.js" - }, + "version": "2.0.0", + "license": "MIT", "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-extglob": { @@ -761,8 +750,7 @@ }, "node_modules/is-wsl": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", "dependencies": { "is-docker": "^2.0.0" }, @@ -876,6 +864,10 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/nanoid": { + "version": "2.1.11", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -884,29 +876,16 @@ }, "node_modules/natural-orderby": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", - "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==", + "license": "MIT", "engines": { "node": "*" } }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, + "version": "2.6.0", + "license": "MIT", "engines": { "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } } }, "node_modules/once": { @@ -919,9 +898,8 @@ } }, "node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "version": "7.0.4", + "license": "MIT", "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" @@ -1116,8 +1094,7 @@ }, "node_modules/sanitize-filename": { "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "license": "WTFPL OR ISC", "dependencies": { "truncate-utf8-bytes": "^1.0.0" } @@ -1146,7 +1123,14 @@ "node_modules/shell-escape": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", - "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" + "integrity": "sha512-uRRBT2MfEOyxuECseCZd28jC1AJ8hmqqneWQ4VWUTgCAFvb3wKU1jLqj6egC4Exrr88ogg3dp+zroH4wJuaXzw==" + }, + "node_modules/shortid": { + "version": "2.2.15", + "license": "MIT", + "dependencies": { + "nanoid": "^2.1.0" + } }, "node_modules/strip-ansi": { "version": "6.0.1", @@ -1186,16 +1170,14 @@ }, "node_modules/temp-dir": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/tempy": { "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", - "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", + "license": "MIT", "dependencies": { "temp-dir": "^1.0.0", "unique-string": "^1.0.0" @@ -1210,15 +1192,9 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", + "license": "WTFPL", "dependencies": { "utf8-byte-length": "^1.0.1" } @@ -1283,8 +1259,7 @@ }, "node_modules/unique-string": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", - "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "license": "MIT", "dependencies": { "crypto-random-string": "^1.0.0" }, @@ -1303,31 +1278,15 @@ }, "node_modules/utf8-byte-length": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", - "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=" + "license": "WTFPL" }, "node_modules/wcwidth": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "license": "MIT", "dependencies": { "defaults": "^1.0.3" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1369,978 +1328,5 @@ "url": "https://github.com/sponsors/sindresorhus" } } - }, - "dependencies": { - "@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^3.3.0" - } - }, - "@eslint-community/regexpp": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", - "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", - "dev": true - }, - "@eslint/eslintrc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", - "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", - "dev": true, - "requires": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.5.2", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - } - }, - "@eslint/js": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", - "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", - "dev": true - }, - "@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", - "dev": true, - "requires": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" - } - }, - "@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true - }, - "@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", - "dev": true - }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - } - }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true - }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - } - }, - "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true - }, - "acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "requires": {} - }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true - }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - }, - "clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "command-exists": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", - "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==" - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, - "crypto-random-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", - "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=" - }, - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "defaults": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", - "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", - "requires": { - "clone": "^1.0.2" - } - }, - "doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "requires": { - "esutils": "^2.0.2" - } - }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, - "eslint": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", - "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", - "dev": true, - "requires": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.40.0", - "@humanwhocodes/config-array": "^0.11.8", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.5.2", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", - "ignore": "^5.2.0", - "import-fresh": "^3.0.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", - "text-table": "^0.2.0" - } - }, - "eslint-scope": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", - "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", - "dev": true, - "requires": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - } - }, - "eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", - "dev": true - }, - "espree": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", - "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", - "dev": true, - "requires": { - "acorn": "^8.8.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - } - }, - "esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "requires": { - "estraverse": "^5.1.0" - } - }, - "esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "requires": { - "estraverse": "^5.2.0" - } - }, - "estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true - }, - "esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true - }, - "expand-home-dir": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/expand-home-dir/-/expand-home-dir-0.0.3.tgz", - "integrity": "sha1-ct6KBIbMKKO71wRjU5iCW1tign0=" - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "requires": { - "reusify": "^1.0.4" - } - }, - "file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "requires": { - "flat-cache": "^3.0.4" - } - }, - "find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "requires": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - } - }, - "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", - "dev": true, - "requires": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" - } - }, - "flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "requires": { - "is-glob": "^4.0.3" - } - }, - "globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, - "requires": { - "type-fest": "^0.20.2" - } - }, - "grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", - "dev": true - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", - "dev": true - }, - "import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "requires": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - } - }, - "imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true - }, - "is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "requires": { - "is-docker": "^2.0.0" - } - }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "js-sdsl": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", - "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", - "dev": true - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "requires": { - "argparse": "^2.0.1" - } - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - } - }, - "locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "requires": { - "p-locate": "^5.0.0" - } - }, - "lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==" - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "natural-orderby": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-2.0.3.tgz", - "integrity": "sha512-p7KTHxU0CUrcOXe62Zfrb5Z13nLvPhSWR/so3kFulUQU0sgUll2Z0LwpsLN351eOOD+hRGu/F1g+6xDfPeD++Q==" - }, - "node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", - "requires": { - "whatwg-url": "^5.0.0" - } - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "requires": { - "wrappy": "1" - } - }, - "open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "requires": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - } - }, - "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", - "dev": true, - "requires": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" - } - }, - "p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "requires": { - "yocto-queue": "^0.1.0" - } - }, - "p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "requires": { - "p-limit": "^3.0.2" - } - }, - "parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "requires": { - "callsites": "^3.0.0" - } - }, - "path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true - }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true - }, - "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true - }, - "queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true - }, - "resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true - }, - "reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true - }, - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "requires": { - "queue-microtask": "^1.2.2" - } - }, - "sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", - "requires": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, - "shell-escape": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", - "integrity": "sha1-aP0CXrBJC09WegJ/C/IkgLX4QTM=" - }, - "strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "requires": { - "ansi-regex": "^5.0.1" - } - }, - "strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "temp-dir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=" - }, - "tempy": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", - "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", - "requires": { - "temp-dir": "^1.0.0", - "unique-string": "^1.0.0" - } - }, - "text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" - }, - "truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=", - "requires": { - "utf8-byte-length": "^1.0.1" - } - }, - "tui-lib": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.4.0.tgz", - "integrity": "sha512-P7PgQHWNK8yVlWZbWm7XLFwirkzQzKNYkhle2YYzj1Ba7fDuh5CITDLvogKFmZSC7RiBC4Y2+2uBpNcRAf1gwQ==", - "requires": { - "natural-orderby": "^3.0.2", - "wcwidth": "^1.0.1" - }, - "dependencies": { - "natural-orderby": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-3.0.2.tgz", - "integrity": "sha512-x7ZdOwBxZCEm9MM7+eQCjkrNLrW3rkBKNHVr78zbtqnMGVNlnDi6C/eUEYgxHNrcbu0ymvjzcwIL/6H1iHri9g==" - } - } - }, - "tui-text-editor": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/tui-text-editor/-/tui-text-editor-0.3.1.tgz", - "integrity": "sha512-ySLdKfUHwxt6W1hub7Qt7smtuwujRHWxMIwdnO+IOzhd2B9naIg07JDr2LISZ3X+SZg0mvBNcGGeTf+L8bcSpw==", - "requires": { - "tui-lib": "^0.1.1" - }, - "dependencies": { - "tui-lib": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/tui-lib/-/tui-lib-0.1.1.tgz", - "integrity": "sha512-QAE4axNCJ42IZSNnc2pLOkFtzHqYFgenDyw88JHHRNd8PXTVO8+JIpJArpgAguopd4MmoYaJbreze0BHoWMXfA==", - "requires": { - "wcwidth": "^1.0.1", - "word-wrap": "^1.2.3" - } - } - } - }, - "type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "requires": { - "prelude-ls": "^1.2.1" - } - }, - "type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true - }, - "unique-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", - "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", - "requires": { - "crypto-random-string": "^1.0.0" - } - }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "requires": { - "punycode": "^2.1.0" - } - }, - "utf8-byte-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", - "integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=" - }, - "wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", - "requires": { - "defaults": "^1.0.3" - } - }, - "webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" - }, - "whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", - "requires": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true - } } } diff --git a/package.json b/package.json index 3639c69..c1630a8 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "mkdirp": "^3.0.1", "natural-orderby": "^2.0.3", "node-fetch": "^2.6.0", - "open": "^7.0.3", + "open": "^7.0.4", "sanitize-filename": "^1.6.3", + "shortid": "^2.2.15", + "tempy": "^0.2.1", "shell-escape": "^0.2.0", "tempy": "^0.2.1", "tui-lib": "^0.4.0", diff --git a/players.js b/players.js index 959bf27..7b11a3b 100644 --- a/players.js +++ b/players.js @@ -255,20 +255,15 @@ export class ControllableMPVPlayer extends MPVPlayer { } setPause(val) { - const wasPaused = this.isPaused - this.isPaused = !!val - - if (this.isPaused !== wasPaused) { - this.sendCommand('cycle', 'pause') + if (!!val !== this.isPaused) { + this.togglePause() } - - // For some reason "set pause" doesn't seem to be working anymore: - // this.sendCommand('set', 'pause', this.isPaused) } setLoop(val) { - this.isLooping = !!val - this.sendCommand('set', 'loop', this.isLooping) + if (!!val !== this.isLooping) { + this.toggleLoop() + } } async kill() { diff --git a/playlist-utils.js b/playlist-utils.js index 0fe26df..dd1d8c8 100644 --- a/playlist-utils.js +++ b/playlist-utils.js @@ -162,15 +162,30 @@ export function flattenGrouplike(grouplike) { // levels in the group tree and returns them as a new group containing those // tracks. - return { - items: grouplike.items.map(item => { - if (isGroup(item)) { - return flattenGrouplike(item).items - } else { - return [item] - } - }).reduce((a, b) => a.concat(b), []) - } + return {items: getFlatTrackList(grouplike)} +} + +export function getFlatTrackList(grouplike) { + // Underlying function for flattenGrouplike. Can be used if you just want to + // get an array and not a grouplike, too. + + return grouplike.items.map(item => { + if (isGroup(item)) { + return getFlatTrackList(item) + } else { + return [item] + } + }).reduce((a, b) => a.concat(b), []) +} + +export function getFlatGroupList(grouplike) { + // Analogue of getFlatTrackList for groups instead of tracks. Returns a flat + // array of all the groups in each level of the provided grouplike. + + return grouplike.items + .filter(isGroup) + .map(item => [item, ...getFlatGroupList(item)]) + .reduce((a, b) => a.concat(b), []) } export function countTotalTracks(item) { @@ -692,3 +707,294 @@ export function getCorrespondingPlayableForFile(item) { const basename = path.basename(item.url, path.extname(item.url)) return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename) } + +export function getPathScore(path1, path2) { + // This function is basically only used in findTrackObject, but it's kinda + // huge and I need to test that it works outside of that context, so I'm + // sticking it on the global scope. Feel free to steal for whatever your + // weird future need for comparing any two paths is! + // + // path1 and path2 should be arrays of group names, according to the path + // you'd follow to open the groups and access a contained track. They should + // *not* include the track name, unless you want those to be considered a + // valid place for the paths to cross over! + // + // -- + // + // A path score is determined to be the number of groups which must be + // traversed across the two paths to find a matching group name and then + // reach the other track under that group. A lower score implies a closer + // match (since score increases not with "closeness" but "separation"). + // + // For example, these two paths are considered to have a score of zero + // against each other ("T" represents the track): + // + // X/B/C/T + // Y/B/C/T + // + // Their separation is zero because, starting from the closest (i.e. top) + // group to either the provided track or the reference data track, it takes + // zero additional steps to reach a group whose name is shared between the + // two paths: those top groups already have the same name. + // + // The above example indicates that the pattern before the closest matching + // path does not matter. Indeed, the actual length of the path could be + // different (W/X/B/C versus Y/B/C for example), and the score would still + // be the same. Parts of the path prepending the closest matching group + // name are thus ommitted from following examples. + // + // These paths, on the other hand, have a score of one: + // + // (...)/C/T + // (...)/C/D/T + // + // The closest matching name in this path is C. It is zero steps further + // from the start of the first path (C is the start); on the other path, + // it is one step further (D must be passed first). Therefore, the total + // steps that must be travelled to reach the start of one path to the + // start of the other by passing through the closest overlapping name is + // one: 0 + 1 = 1. + // + // In determining which of two paths are a closer match to a provided + // reference path, it's important to remember that a lower score (implying + // less separation) is better. Though we'll see the following example is + // probably more common across most music libraries, a reasonably natural + // example of the path structures above occurring in a music library could + // be this: an artist directory containing both albums and stray tracks, + // where one track apparently appears as both a stray track file and in an + // adjacent album directory; or, a mixtape which contains adjacent to its + // mixed-segment track listing a folder of the unmixed segments. + // + // These paths have a score of two: + // + // (...)/B/C/T + // (...)/B/D/T + // + // With the above examples, this one is fairly self explanatory. In this + // case, the closest matching group, B, is one step away from the start + // point (the first group before the track, i.e, the top name in the path) + // in both paths. Summed, the distance (and thus the score) is two. + // + // This example demonstrates what is probably a more realistic case of two + // tracks resembling each other (e.g. having the same name or source) but + // not sharing the same path: if B represents an artist, and C & D stand in + // place (in this example) of the names of that artist's albums, then it is + // reasonable to say the directories for the album are slightly different + // across the two paths. This could be the case for two users who ended up + // naming the album directory differently, or for one user restoring from + // their own backend/playlist after having adjusted the naming structure of + // their music library. It's also possible that there could simply be two + // albums by the same artist which contain a track of the same name; in + // that case, the path score implementation is doing exactly its job by + // indicating that these tracks would have a greater score (meaning further + // separation) than when checking against the track belonging to the same + // release. (If there is concern that such a track should not match at all + // because it may be a remarkably different track, other factors of + // resemblance -- position in album, duration, etc -- can be used to add + // detail to the apparent level of resemblance then.) + // + // -- + // + // A note on determining which name is the "closest" -- consider + // the following two paths: + // + // A/X/B/C/D/E/T + // A/Y/E/B/C/D/T + // + // There are many names which appear in both paths. So which do we treat + // as the closest? Well, what we're looking for is the shortest path across + // both paths, passing through at a particular name. To do this, we simply + // calculate the score for each name in the intersection of both paths + // (i.e. every name which shows up in both paths) using the same algorithm + // described above (sum of the distance from the start of either path). + // Then we take the lowest resultant score, and use that as the final score + // which is returned out of this function. + // + // TODO: There are probably optimizations to be made as far as avoiding + // processing every overlapping name goes (particularly once it's + // determined that no other path could be determined), but honestly + // I'm pretty sure if I tried to write an algorithm taking *that* + // into account, I'd end up screwing it up. :P So for now, we just + // do a simple filter and reduce operation. + // + // If the intersection of the two paths is empty (i.e. there is no overlap), + // we return the otherwise nonsense value, -1. + + const union = Array.from(new Set([...path1, ...path2])) + const intersection = union.filter( + name => path1.includes(name) && path2.includes(name)) + + if (!intersection.length) { + return -1 + } + + const reversed1 = path1.reverse() + const reversed2 = path2.reverse() + + const scores = intersection.map( + name => reversed1.indexOf(name) + reversed2.indexOf(name)) + + return scores.reduce((a, b) => a < b ? a : b) +} + +export function getNameScore(name1, name2) { + // Pretty simple algorithm here: we're looking for the longest continuous + // series of words which is shared between both names. The score is the + // length of that series, so a higher score is better (and a zero score + // means no overlap). + + // TODO: + // This ain't perfect! Case example: User A has library structure: + // + // Very Cool Album/ + // 01 Beans + // + // User B has library structure: + // + // Very Cool Album/ + // Very Cool Album- 01 Beans + // + // Now if user B queues 'Very Cool Album- 01 Beans', the search will match + // the *group* 'Very Cool Album' on User A's end, because that name has a + // 3-word match, in comparison to the track '01 Beans', which is only a + // 2-word match. Not sure what a proper solution here would be, but probably + // it'd involve somehow prioritizing series of words which match closer to + // the end! + + // Split into chunks of word characters, taking out any non-word (\W) + // characters between. + const toWords = name => name.split(/\W+/) + + const words1 = toWords(name1) + const words2 = toWords(name2) + + const getLongestMatch = (parse, against) => { + let longestMatch = 0 + + for (let i = 0; i < parse.length; i++) { + const word = parse[i] + + for (let j = 0; j < against.length; j++) { + if (against[j] !== word) { + continue + } + + let offset = 1 + while ( + parse[i + offset] && + against[i + offset] && + parse[i + offset] === against[j + offset] + ) { + offset++ + } + + if (offset > longestMatch) { + longestMatch = offset + } + } + } + + return longestMatch + } + + return Math.max( + getLongestMatch(words1, words2), + getLongestMatch(words2, words1) + ) +} + +export function findItemObject(referenceData, possibleChoices) { + // Finds the item object in the provided choices which most closely resembles + // the provided reference data. This is used for maintaining the identity of + // item objects when reloading a playlist (see serialized-backend.js). It's + // also usable in synchronizing the identity of items across linked clients + // (see socket.js). + + // Reference data includes item NAME and item PATH (names of parent groups). + // Specifics of how existing item objects are determined to resemble this + // data are laid out next to the relevant implementation code. + // + // TODO: Should track number be considered here? + // TODO: Should track "metadata" (duration, md5?) be considered too? + // This in particular prompts questions of what the purpose of matching + // tracks *is*, and in considering those I lean towards "no" here, but + // it's probably worth looking at more in the future. (TM.) + + function getItemPathScore(item) { + if (!referenceData.path) { + return null + } + + const path1 = referenceData.path.slice() + const path2 = getItemPath(item).slice(0, -1).map(group => group.name) + return getPathScore(path1, path2) + } + + function getItemNameScore(item) { + const name1 = referenceData.name + const name2 = item.name + return getNameScore(name1, name2) + } + + // The only items which will be considered at all are those which at least + // partially match the reference name. + const baselineResemble = possibleChoices.map(item => ({ + item, + nameScore: getItemNameScore(item) + })).filter(item => item.nameScore > 0) + + // If no item matches the baseline conditions for resemblance at all, + // return null. It's up to the caller to decide what to do in this case, + // e.g. reporting that no item was found, or creating a new item object + // from the reference data altogether. + if (!baselineResemble.length) { + return null + } + + // Find the "reasons" these items resemble the reference data; these will + // be used as the factors in calculating which item resembles closest. + const reasons = baselineResemble.map(({item, nameScore}) => ({ + item, + pathScore: getItemPathScore(item), + nameScore + })) + + // TODO: Are there circumstances in which a strong path score should be + // prioritized in spite of weaker name score? + + // Sort by closest matching filenames first. + reasons.sort((a, b) => b.nameScore - a.nameScore) + + // Filter only the best name matches. + const bestNameScore = reasons[0].nameScore + const bestName = reasons.filter(({ nameScore }) => nameScore === bestNameScore) + + // Then choose the best matching path. + const sharePath = bestName.filter(({ pathScore }) => pathScore >= 0) + const mostResembles = (sharePath.length + ? sharePath.reduce((a, b) => a.pathScore < b.pathScore ? a : b) + : reasons[0]) + + return mostResembles.item +} + +export function walkSharedStructure(modelGrouplike, ...additionalGrouplikesAndCallback) { + // Recursively traverse (aka "walk") a model grouplike and follow the same + // path through one or more additional grouplikes, running a callback with + // the item at that path from each of the grouplikes (model and additional). + + const additionalGrouplikes = additionalGrouplikesAndFunction.slice(0, -1) + const callback = additionalGrouplikesAndCallback[additionalGrouplikesAndFunction.length - 1] + + const recursive = (model, ...additional) => { + for (let i = 0; i < model.items.length; i++) { + const modelItem = model.items[i] + const additionalItems = additional.map(a => a.items[i]) + callback(modelItem, ...additionalItems) + + if (isGroup(modelItem)) { + recursive(modelItem, ...additionalItems) + } + } + } +} diff --git a/serialized-backend.js b/serialized-backend.js new file mode 100644 index 0000000..bdac54c --- /dev/null +++ b/serialized-backend.js @@ -0,0 +1,228 @@ +// Tools for serializing a backend into a JSON-stringifiable object format, +// and for deserializing this format and loading its contained data into an +// existing backend instance. +// +// Serialized data includes the list of queue players and each player's state +// (queued items, playback position, etc). +// +// Serialized backend data can be used for a variety of purposes, such as +// writing the data to a file and saving it for later use, or transferring +// it over an internet connection to synchronize playback with a friend. +// (The code in socket.js exists to automate this process, as well as to +// provide a link so that changes to the queue or playback are synchronized +// in real-time.) +// +// TODO: Changes might be necessary all throughout the program to support +// having any number of objects refer to "the same track", as will likely be +// the case when restoring from a serialized backend. One way to handle this +// would be to (perhaps through the existing record store code) keep a handle +// on each of "the same track", which would be accessed by something like a +// serialized ID (ala symbols), or maybe just the track name / source URL. + +'use strict' + +import { + isGroup, + isTrack, + findItemObject, + flattenGrouplike, + getFlatGroupList, + getFlatTrackList, + getItemPath +} from './playlist-utils.js' + +const referenceDataSymbol = Symbol('Restored reference data') + +function getPlayerInfo(queuePlayer) { + const { player } = queuePlayer + return { + time: queuePlayer.time, + isLooping: player.isLooping, + isPaused: player.isPaused, + volume: player.volume + } +} + +export function saveBackend(backend) { + return { + queuePlayers: backend.queuePlayers.map(QP => ({ + id: QP.id, + playingTrack: saveItemReference(QP.playingTrack), + queuedTracks: QP.queueGrouplike.items.map(saveItemReference), + pauseNextTrack: QP.pauseNextTrack, + playerInfo: getPlayerInfo(QP) + })) + } +} + +export async function restoreBackend(backend, data) { + if (data.queuePlayers) { + if (data.queuePlayers.length === 0) { + return + } + + for (const qpData of data.queuePlayers) { + const QP = await backend.addQueuePlayer() + QP[referenceDataSymbol] = qpData + + QP.id = qpData.id + + QP.queueGrouplike.items = qpData.queuedTracks.map(refData => restoreNewItem(refData)) + + QP.player.setVolume(qpData.playerInfo.volume) + QP.player.setLoop(qpData.playerInfo.isLooping) + + QP.on('playing', () => { + QP[referenceDataSymbol].playingTrack = null + QP[referenceDataSymbol].playerInfo = null + }) + } + + // We remove the old queue players after the new ones have been added, + // because the backend won't let us ever have less than one queue player + // at a time. + while (backend.queuePlayers.length !== data.queuePlayers.length) { + backend.removeQueuePlayer(backend.queuePlayers[0]) + } + } +} + +async function restorePlayingTrack(queuePlayer, playedTrack, playerInfo) { + const QP = queuePlayer + await QP.stopPlaying() + QP.play(playedTrack, playerInfo.time || 0, playerInfo.isPaused) +} + +export function updateRestoredTracksUsingPlaylists(backend, playlists) { + // Utility function to restore the "identities" of tracks (i.e. which objects + // they are represented by) queued or playing in the provided backend, + // pulling possible track identities from the provided playlists. + // + // How well provided tracks resemble the ones existing in the backend (which + // have not already been replaced by an existing track) is calculated with + // the algorithm implemented in findItemObject, combining all provided + // playlists (simply putting them all in a group) to allow the algorithm to + // choose from all playlists equally at once. + // + // This function should be called after restoring a playlist and whenever + // a new source playlist is added (a new tab opened, etc). + // + // TODO: Though this helps to combat issues with restoring track identities + // when restoring from a saved backend, it could be expanded to restore from + // closed sources as well (reference data would have to be automatically + // saved on the tracks independently of save/restore in order to support + // this sort of functionality). Note this would still face difficulties with + // opening two identical playlists (i.e. the same playlist twice), since then + // identities would be equally correctly picked from either source; this is + // an inevitable issue with the way identities are resolved, but could be + // lessened in the UI by simply opening a new view (rather than a whole new + // load, with new track identities) when a playlist is opened twice at once. + + const possibleChoices = getFlatTrackList({items: playlists}) + + for (const QP of backend.queuePlayers) { + let playingDataToRestore + + const qpData = (QP[referenceDataSymbol] || {}) + const waitingTrackData = qpData.playingTrack + if (waitingTrackData) { + playingDataToRestore = waitingTrackData + } else if (QP.playingTrack) { + playingDataToRestore = QP.playingTrack[referenceDataSymbol] + } + + if (playingDataToRestore) { + const found = findItemObject(playingDataToRestore, possibleChoices) + if (found) { + restorePlayingTrack(QP, found, qpData.playerInfo || getPlayerInfo(QP)) + } + } + + QP.queueGrouplike.items = QP.queueGrouplike.items.map(track => { + const refData = track[referenceDataSymbol] + if (!refData) { + return track + } + + return findItemObject(refData, possibleChoices) || track + }) + + QP.emit('queue updated') + } +} + +export function saveItemReference(item) { + // Utility function to generate reference data for a track or grouplike, + // according to the format taken by findItemObject. + + if (isTrack(item)) { + return { + name: item.name, + path: getItemPath(item).slice(0, -1).map(group => group.name), + downloaderArg: item.downloaderArg + } + } else if (isGroup(item)) { + return { + name: item.name, + path: getItemPath(item).slice(0, -1).map(group => group.name), + items: item.items.map(saveItemReference) + } + } else if (item) { + return item + } else { + return null + } +} + +export function restoreNewItem(referenceData, playlists) { + // Utility function to restore a new item. If you're restoring tracks + // already present in a backend, use the specific function for that, + // updateRestoredTracksUsingPlaylists. + // + // This function takes a playlists array like the function for restoring + // tracks in a backend, but in this function, it's optional: if not provided, + // it will simply skip searching for a resembling track and return a new + // track object right away. + + let found + if (playlists) { + let possibleChoices + if (referenceData.downloaderArg) { + possibleChoices = getFlatTrackList({items: playlists}) + } else if (referenceData.items) { + possibleChoices = getFlatGroupList({items: playlists}) + } + if (possibleChoices) { + found = findItemObject(referenceData, possibleChoices) + } + } + + if (found) { + return found + } else if (referenceData.downloaderArg) { + return { + [referenceDataSymbol]: referenceData, + name: referenceData.name, + downloaderArg: referenceData.downloaderArg + } + } else if (referenceData.items) { + return { + [referenceDataSymbol]: referenceData, + name: referenceData.name, + items: referenceData.items.map(item => restoreNewItem(item, playlists)) + } + } else { + return { + [referenceDataSymbol]: referenceData, + name: referenceData.name + } + } +} + +export function getWaitingTrackData(queuePlayer) { + // Utility function to get reference data for the track which is currently + // waiting to be played, once a resembling track is found. This should only + // be used to reflect that data in the user interface. + + return (queuePlayer[referenceDataSymbol] || {}).playingTrack +} diff --git a/socket.js b/socket.js new file mode 100644 index 0000000..59f70d9 --- /dev/null +++ b/socket.js @@ -0,0 +1,992 @@ +// Tools for hosting an MTUI party over a socket server. Comparable in idea to +// telnet.js, but for interfacing over commands rather than hosting all client +// UIs on one server. The intent of the code in this file is to allow clients +// to connect and interface with each other, while still running all processes +// involved in mtui on their own machines -- so mtui will download and play +// music using each connected machine's own internet connection and speakers. + +// TODO: Option to display listing items which aren't available on all +// connected devices. +// +// TODO: While having a canonical backend is useful for maintaining a baseline +// playback position and queue/library with which to sync clients, it probably +// shouldn't be necessary to have an actual JS reference to that backend. +// Making communication with the canonical backend work over socket (in as much +// as possible the same way we do current socket communication) means the +// server can be run on a remote host without requiring access to the music +// library from there. This would be handy for people with a VPN with its own +// hostname and firewall protections! + +// single quotes & no semicolons time babey + +import EventEmitter from 'node:events' +import net from 'node:net' + +import shortid from 'shortid' + +import { + getTimeStringsFromSec, + parseWithoutPrototype, + silenceEvents, +} from './general-util.js' + +import { + parentSymbol, + updateGroupFormat, + updateTrackFormat, + isTrack, + isGroup, +} from './playlist-utils.js' + +import { + restoreBackend, + restoreNewItem, + saveBackend, + saveItemReference, + updateRestoredTracksUsingPlaylists, +} from './serialized-backend.js' + +// This is expected to be the same across both the client and the server. +// There will probably be inconsistencies between sender clients and receiving +// clients / the server otherwise. +const DEFAULT_NICKNAME = '(Unnamed)' + +export const originalSymbol = Symbol('Original item') + +function serializePartySource(item) { + // Turn an item into a sanitized, compact format for sharing with the server + // and other sockets in the party. + // + // TODO: We'll probably need to assign a unique ID to the root item, since + // otherwise we don't have a way to target it to un-share it. + + if (isGroup(item)) { + return [item.name, ...item.items.map(serializePartySource).filter(Boolean)] + } else if (isTrack(item)) { + return item.name + } else { + return null + } +} + +function deserializePartySource(source, parent = null) { + // Reconstruct a party source into the ordinary group/track format. + + const recursive = source => { + if (Array.isArray(source)) { + return {name: source[0], items: source.slice(1).map(recursive).filter(Boolean)} + } else if (typeof source === 'string') { + return {name: source, downloaderArg: '-'} + } else { + return null + } + } + + const top = recursive(source) + + const item = (isGroup(top) + ? updateGroupFormat(top) + : updateTrackFormat(top)) + + if (parent) { + item[parentSymbol] = parent + } + + return item +} + +function serializeCommandToData(command) { + // Turn a command into a string/buffer that can be sent over a socket. + return JSON.stringify(command) +} + +function deserializeDataToCommand(data) { + // Turn data received from a socket into a command that can be processed as + // an action to apply to the mtui backend. + return parseWithoutPrototype(data) +} + +function namePartySources(nickname) { + return `Party Sources - ${nickname}` +} + +function isItemRef(ref) { + if (ref === null || typeof ref !== 'object') { + return false + } + + // List of true/false/null. False means *invalid* reference data; null + // means *nonpresent* reference data. True means present and valid. + const conditionChecks = [ + 'name' in ref ? typeof ref.name === 'string' : null, + 'path' in ref ? Array.isArray(ref.path) && ref.path.every(n => typeof n === 'string') : null, + 'downloaderArg' in ref ? ( + !('items' in ref) && + typeof ref.downloaderArg === 'string' + ) : null, + 'items' in ref ? ( + !('downloaderArg' in ref) && + Array.isArray(ref.items) && + ref.items.every(isItemRef) + ) : null + ] + + if (conditionChecks.includes(false)) { + return false + } + + if (!conditionChecks.includes(true)) { + return false + } + + return true +} + +function validateCommand(command) { + // TODO: Could be used to validate "against" a backend, but for now it just + // checks data types. + + if (typeof command !== 'object') { + return false + } + + if (!['server', 'client'].includes(command.sender)) { + return false + } + + switch (command.sender) { + case 'server': + switch (command.code) { + case 'initialize party': + return ( + typeof command.backend === 'object' && + typeof command.socketInfo === 'object' && + Object.values(command.socketInfo).every(info => ( + typeof info.nickname === 'string' && + Array.isArray(info.sharedSources) + )) + ) + case 'set socket id': + return typeof command.socketId === 'string' + } + // No break here; servers can send commands which typically come from + // clients too. + case 'client': + switch (command.code) { + case 'announce join': + return true + case 'clear queue': + return typeof command.queuePlayer === 'string' + case 'clear queue past': + case 'clear queue up to': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.track) + ) + case 'distribute queue': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) && + (!command.opts || typeof command.opts === 'object' && ( + ( + !command.opts.how || + ['evenly', 'randomly'].includes(command.opts.how) + ) && + ( + !command.opts.rangeEnd || + ['end-of-queue'].includes(command.opts.rangeEnd) || + typeof command.opts.rangeEnd === 'number' + ) + )) + ) + case 'play': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.track) + ) + case 'queue': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) && + ( + isItemRef(command.afterItem) || + [null, 'FRONT'].includes(command.afterItem) + ) && + (!command.opts || typeof command.opts === 'object' && ( + ( + !command.opts.movePlayingTrack || + typeof command.opts.movePlayingTrack === 'boolean' + ) + )) + ) + case 'restore queue': + return ( + typeof command.queuePlayer === 'string' && + Array.isArray(command.tracks) && + command.tracks.every(track => isItemRef(track)) && + ['shuffle'].includes(command.why) + ) + case 'seek to': + return ( + typeof command.queuePlayer === 'string' && + typeof command.time === 'number' + ) + case 'set nickname': + return ( + typeof command.nickname === 'string' && + typeof command.oldNickname === 'string' && + command.nickname.length >= 1 && + command.nickname.length <= 12 + ) + case 'set pause': + return ( + typeof command.queuePlayer === 'string' && + typeof command.paused === 'boolean' && + ( + typeof command.startingTrack === 'boolean' && + command.sender === 'server' + ) || !command.startingTrack + ) + case 'share with party': + return ( + typeof command.item === 'string' || + Array.isArray(command.item) + ) + case 'status': + return ( + command.status === 'done playing' || + ( + command.status === 'ready to resume' && + typeof command.queuePlayer === 'string' + ) || + command.status === 'sync playback' + ) + case 'stop playing': + return typeof command.queuePlayer === 'string' + case 'unqueue': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) + ) + } + break + } + + return false +} + +function perLine(handleLine) { + // Wrapper function to run a callback for each line provided to the wrapped + // callback. Maintains a "partial" variable so that a line may be broken up + // into multiple chunks before it is sent. Also supports handling multiple + // lines (including the conclusion to a previously received partial line) + // being received at once. + + let partial = '' + return data => { + const text = data.toString() + const lines = text.split('\n') + if (lines.length === 1) { + partial += text + } else { + handleLine(partial + lines[0]) + for (const line of lines.slice(1, -1)) { + handleLine(line) + } + partial = lines[lines.length - 1] + } + } +} + +export function makeSocketServer() { + // The socket server has two functions: to maintain a "canonical" backend + // and synchronize newly connected clients with the relevent data in this + // backend, and to receive command data from clients and relay this to + // other clients. + // + // makeSocketServer doesn't actually start the server listening on a port; + // that's the responsibility of the caller (use server.listen()). + + const server = new net.Server() + const socketMap = Object.create(null) + + // Keeps track of details to share with newly joining sockets for + // synchronization. + const socketInfoMap = Object.create(null) + + server.canonicalBackend = null + + // <variable> -> queue player id -> array: socket + const readyToResume = Object.create(null) + const donePlaying = Object.create(null) + + server.on('connection', socket => { + const socketId = shortid.generate() + + const socketInfo = { + hasAnnouncedJoin: false, + nickname: DEFAULT_NICKNAME, + + // Unlike in client code, this isn't an array of actual playlist items; + // rather, it's the intermediary format used when transferring between + // client and server. + sharedSources: [] + } + + socketMap[socketId] = socket + socketInfoMap[socketId] = socketInfo + + socket.on('close', () => { + if (socketId in socketMap) { + delete socketMap[socketId] + delete socketInfoMap[socketId] + } + }) + + socket.on('data', perLine(line => { + // Parse data as a command and validate it. If invalid, drop this data. + + let command + try { + command = deserializeDataToCommand(line) + } catch (error) { + return + } + + command.sender = 'client' + command.senderSocketId = socketId + command.senderNickname = socketInfo.nickname + + if (!validateCommand(command)) { + return + } + + // If the socket hasn't announced its joining yet, it only has access to + // a few commands. + + if (!socketInfo.hasAnnouncedJoin) { + if (![ + 'announce join', + 'set nickname' + ].includes(command.code)) { + return + } + } + + // If it's a status command, respond appropriately, and return so that it + // is not relayed. + + if (command.code === 'status') { + switch (command.status) { + case 'done playing': { + const doneSockets = donePlaying[command.queuePlayer] + if (doneSockets && !doneSockets.includes(socketId)) { + doneSockets.push(socketId) + if (doneSockets.length === Object.keys(socketMap).length) { + // determine next track + for (const socket of Object.values(socketMap)) { + // play next track + } + delete donePlaying[command.queuePlayer] + } + } + break + } + case 'ready to resume': { + const readySockets = readyToResume[command.queuePlayer] + if (readySockets && !readySockets.includes(socketId)) { + readySockets.push(socketId) + if (readySockets.length === Object.keys(socketMap).length) { + // const QP = server.canonicalBackend.queuePlayers.find(QP => QP.id === command.queuePlayer) + // silenceEvents(QP, ['set pause'], () => QP.setPause(false)) + for (const socket of Object.values(socketMap)) { + socket.write(serializeCommandToData({ + sender: 'server', + code: 'set pause', + queuePlayer: command.queuePlayer, + startingTrack: true, + paused: false + }) + '\n') + donePlaying[command.queuePlayer] = [] + } + delete readyToResume[command.queuePlayer] + } + } + break + } + case 'sync playback': + for (const QP of server.canonicalBackend.queuePlayers) { + if (QP.timeData) { + socket.write(serializeCommandToData({ + sender: 'server', + code: 'seek to', + queuePlayer: QP.id, + time: QP.timeData.curSecTotal + }) + '\n') + socket.write(serializeCommandToData({ + sender: 'server', + code: 'set pause', + queuePlayer: QP.id, + startingTrack: true, + paused: QP.player.isPaused + }) + '\n') + } + } + break + } + return + } + + // If it's a 'play' command, set up a new readyToResume array. + + if (command.code === 'play') { + readyToResume[command.queuePlayer] = [] + } + + // If it's a 'set nickname' command, save the nickname. + // Also attach the old nickname for display in log messages. + + if (command.code === 'set nickname') { + command.oldNickname = socketInfo.nickname + command.senderNickname = socketInfo.nickname + socketInfo.nickname = command.nickname + } + + // If it's a 'share with party' command, keep track of the item being + // shared, so we can synchronize newly joining sockets with it. + + if (command.code === 'share with party') { + const { sharedSources } = socketInfoMap[socketId] + sharedSources.push(command.item) + } + + // If it's an 'announce join' command, mark the variable for this! + + if (command.code === 'announce join') { + socketInfo.hasAnnouncedJoin = true; + } + + // If the socket hasn't announced its joining yet, don't relay the + // command. (Since hasAnnouncedJoin gets set above, 'announce join' + // will pass this condition.) + + if (!socketInfo.hasAnnouncedJoin) { + return + } + + // Relay the command to client sockets besides the sender. + + const otherSockets = Object.values(socketMap).filter(s => s !== socket) + + for (const socket of otherSockets) { + socket.write(serializeCommandToData(command) + '\n') + } + })) + + const savedBackend = saveBackend(server.canonicalBackend) + + for (const qpData of savedBackend.queuePlayers) { + if (qpData.playerInfo) { + qpData.playerInfo.isPaused = true + } + } + + socket.write(serializeCommandToData({ + sender: 'server', + code: 'set socket id', + socketId + }) + '\n') + + socket.write(serializeCommandToData({ + sender: 'server', + code: 'initialize party', + backend: savedBackend, + socketInfo: socketInfoMap + }) + '\n') + }) + + return server +} + +export function makeSocketClient() { + // The socket client connects to a server and sends/receives commands to/from + // that server. This doesn't actually connect the socket to a port/host; that + // is the caller's responsibility (use client.socket.connect()). + + const client = new EventEmitter() + client.socket = new net.Socket() + client.nickname = DEFAULT_NICKNAME + client.socketId = null // Will be received from server. + + client.sendCommand = function(command) { + const data = serializeCommandToData(command) + client.socket.write(data + '\n') + client.emit('sent command', command) + } + + client.socket.on('data', perLine(line => { + // Same sort of "guarding" deserialization/validation as in the server + // code, because it's possible the client and server backends mismatch. + + let command + try { + command = deserializeDataToCommand(line) + } catch (error) { + return + } + + if (!validateCommand(command)) { + return + } + + client.emit('command', command) + })) + + return client +} + +export function attachBackendToSocketClient(backend, client) { + // All actual logic for instances of the mtui backend interacting with each + // other through commands lives here. + + let hasAnnouncedJoin = false + + const sharedSources = { + name: namePartySources(client.nickname), + isPartySources: true, + items: [] + } + + const socketInfoMap = Object.create(null) + + const getPlaylistSources = () => + sharedSources.items.map(item => item[originalSymbol]) + + backend.setHasAnnouncedJoin(false) + backend.setAlwaysStartPaused(true) + backend.setWaitWhenDonePlaying(true) + + function logCommand(command) { + const nickToMessage = nickname => `\x1b[32;1m${nickname}\x1b[0m` + const itemToMessage = item => `\x1b[32m"${item.name}"\x1b[0m` + + let senderNickname = command.sender === 'server' ? 'the server' : command.senderNickname + // TODO: This should use a unique sender ID, provided by the server and + // corresponding to the socket. This could be implemented into the UI! + // But also, right now users can totally pretend to be the server by... + // setting their nickname to "the server", which is silly. + const sender = senderNickname + + let actionmsg = `sent ${command.code} (no action message specified)` + let code = command.code + let mayCombine = false + let isVerbose = false + + switch (command.code) { + case 'announce join': + actionmsg = `joined the party` + break + case 'clear queue': + actionmsg = 'cleared the queue' + break + case 'clear queue past': + actionmsg = `cleared the queue past ${itemToMessage(command.track)}` + break + case 'clear queue up to': + actionmsg = `cleared the queue up to ${itemToMessage(command.track)}` + break + case 'distribute queue': + actionmsg = `distributed ${itemToMessage(command.topItem)} across the queue ${command.opts.how}` + break + case 'initialize party': + return + case 'play': + actionmsg = `started playing ${itemToMessage(command.track)}` + break + case 'queue': { + let afterMessage = '' + if (isItemRef(command.afterItem)) { + afterMessage = ` after ${itemToMessage(command.afterItem)}` + } else if (command.afterItem === 'FRONT') { + afterMessage = ` at the front of the queue` + } + actionmsg = `queued ${itemToMessage(command.topItem)}` + afterMessage + break + } + case 'restore queue': + if (command.why === 'shuffle') { + actionmsg = 'shuffled the queue' + } + break + case 'share with party': + // TODO: This isn't an outrageously expensive operation, but it still + // seems a little unnecessary to deserialize it here if we also do that + // when actually processing the source? + actionmsg = `shared ${itemToMessage(deserializePartySource(command.item))} with the party` + break + case 'seek to': + // TODO: the second value here should be the duration of the track + // (this will make values like 0:0x:yy / 1:xx:yy appear correctly) + actionmsg = `seeked to ${getTimeStringsFromSec(command.time, command.time).timeDone}` + mayCombine = true + break + case 'set nickname': + actionmsg = `updated their nickname (from ${nickToMessage(command.oldNickname)})` + senderNickname = command.nickname + break + case 'set socket id': + return + case 'set pause': + if (command.paused) { + actionmsg = 'paused the player' + } else { + actionmsg = 'resumed the player' + } + break + case 'stop playing': + actionmsg = 'stopped the player' + break + case 'unqueue': + actionmsg = `removed ${itemToMessage(command.topItem)} from the queue` + break + case 'status': + isVerbose = true + switch (command.status) { + case 'ready to resume': + actionmsg = `is ready to play!` + break + case 'done playing': + actionmsg = `has finished playing` + break + case 'sync playback': + actionmsg = `synced playback with the server` + break + default: + actionmsg = `sent status "${command.status}"` + break + } + break + } + const text = `${nickToMessage(senderNickname)} ${actionmsg}` + backend.showLogMessage({ + text, + code, + sender, + mayCombine, + isVerbose + }) + } + + client.on('sent command', command => { + command.senderNickname = client.nickname + logCommand(command) + }) + + client.on('command', async command => { + logCommand(command) + switch (command.sender) { + case 'server': + switch (command.code) { + case 'set socket id': + client.socketId = command.socketId + socketInfoMap[command.socketId] = { + nickname: client.nickname, + sharedSources + } + backend.loadSharedSources(command.socketId, sharedSources) + return + case 'initialize party': + for (const [ socketId, info ] of Object.entries(command.socketInfo)) { + const nickname = info.nickname + + const sharedSources = { + name: namePartySources(nickname), + isPartySources: true + } + + sharedSources.items = info.sharedSources.map( + item => deserializePartySource(item, sharedSources)) + + socketInfoMap[socketId] = { + nickname, + sharedSources + } + + backend.loadSharedSources(socketId, sharedSources) + } + await restoreBackend(backend, command.backend) + // backend.on('QP: playing', QP => { + // QP.once('received time data', () => { + // client.sendCommand({code: 'status', status: 'sync playback'}) + // }) + // }) + return + } + // Again, no break. Client commands can come from the server. + case 'client': { + let QP = ( + command.queuePlayer && + backend.queuePlayers.find(QP => QP.id === command.queuePlayer) + ) + + switch (command.code) { + case 'announce join': { + const sharedSources = { + name: namePartySources(command.senderNickname), + isPartySources: true, + items: [] + } + socketInfoMap[command.senderSocketId] = { + nickname: command.senderNickname, + sharedSources + } + backend.loadSharedSources(command.senderSocketId, sharedSources) + return + } + case 'clear queue': + if (QP) silenceEvents(QP, ['clear queue'], () => QP.clearQueue()) + return + case 'clear queue past': + if (QP) silenceEvents(QP, ['clear queue past'], () => QP.clearQueuePast( + restoreNewItem(command.track, getPlaylistSources()) + )) + return + case 'clear queue up to': + if (QP) silenceEvents(QP, ['clear queue up to'], () => QP.clearQueueUpTo( + restoreNewItem(command.track, getPlaylistSources()) + )) + return + case 'distribute queue': + if (QP) silenceEvents(QP, ['distribute queue'], () => QP.distributeQueue( + restoreNewItem(command.topItem), + { + how: command.opts.how, + rangeEnd: command.opts.rangeEnd + } + )) + return + case 'play': + if (QP) { + QP.once('received time data', data => { + client.sendCommand({ + code: 'status', + status: 'ready to resume', + queuePlayer: QP.id + }) + }) + silenceEvents(QP, ['playing'], () => QP.play( + restoreNewItem(command.track, getPlaylistSources()) + )) + } + return + case 'queue': + if (QP) silenceEvents(QP, ['queue'], () => QP.queue( + restoreNewItem(command.topItem, getPlaylistSources()), + isItemRef(command.afterItem) ? restoreNewItem(command.afterItem, getPlaylistSources()) : command.afterItem, + { + movePlayingTrack: command.opts.movePlayingTrack + } + )) + return + case 'restore queue': + if (QP) { + QP.replaceAllItems(command.tracks.map( + refData => restoreNewItem(refData, getPlaylistSources()) + )) + } + return + case 'seek to': + if (QP) silenceEvents(QP, ['seek to'], () => QP.seekTo(command.time)) + return + case 'set nickname': { + const info = socketInfoMap[command.senderSocketId] + info.nickname = command.senderNickname + info.sharedSources.name = namePartySources(command.senderNickname) + backend.sharedSourcesUpdated(client.socketId, info.sharedSources) + return + } + case 'set pause': { + // TODO: there's an event leak here when toggling pause while + // nothing is playing + let playingThisTrack = true + QP.once('playing new track', () => { + playingThisTrack = false + }) + setTimeout(() => { + if (playingThisTrack) { + if (QP) silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused)) + } + }, command.startingTrack ? 500 : 0) + return + } + case 'share with party': { + const { sharedSources } = socketInfoMap[command.senderSocketId] + const deserialized = deserializePartySource(command.item, sharedSources) + sharedSources.items.push(deserialized) + backend.sharedSourcesUpdated(command.senderSocketId, sharedSources) + return + } + case 'stop playing': + if (QP) silenceEvents(QP, ['playing'], () => QP.stopPlaying()) + return + case 'unqueue': + if (QP) silenceEvents(QP, ['unqueue'], () => QP.unqueue( + restoreNewItem(command.topItem, getPlaylistSources()) + )) + return + } + } + } + }) + + backend.on('QP: clear queue', queuePlayer => { + client.sendCommand({ + code: 'clear queue', + queuePlayer: queuePlayer.id + }) + }) + + backend.on('QP: clear queue past', (queuePlayer, track) => { + client.sendCommand({ + code: 'clear queue past', + queuePlayer: queuePlayer.id, + track: saveItemReference(track) + }) + }) + + backend.on('QP: clear queue up to', (queuePlayer, track) => { + client.sendCommand({ + code: 'clear queue up to', + queuePlayer: queuePlayer.id, + track: saveItemReference(track) + }) + }) + + backend.on('QP: distribute queue', (queuePlayer, topItem, opts) => { + client.sendCommand({ + code: 'distribute queue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem), + opts + }) + }) + + backend.on('QP: done playing', queuePlayer => { + client.sendCommand({ + code: 'status', + status: 'done playing', + queuePlayer: queuePlayer.id + }) + }) + + backend.on('QP: playing', (queuePlayer, track) => { + if (track) { + client.sendCommand({ + code: 'play', + queuePlayer: queuePlayer.id, + track: saveItemReference(track) + }) + queuePlayer.once('received time data', data => { + client.sendCommand({ + code: 'status', + status: 'ready to resume', + queuePlayer: queuePlayer.id + }) + }) + } else { + client.sendCommand({ + code: 'stop playing', + queuePlayer: queuePlayer.id + }) + } + }) + + let n = 0 + backend.on('QP: queue', (queuePlayer, topItem, afterItem, opts) => { + client.sendCommand({ + code: 'queue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem), + afterItem: saveItemReference(afterItem), + opts + }) + }) + + function handleSeek(queuePlayer) { + client.sendCommand({ + code: 'seek to', + queuePlayer: queuePlayer.id, + time: queuePlayer.time + }) + } + + backend.on('QP: seek ahead', handleSeek) + backend.on('QP: seek back', handleSeek) + backend.on('QP: seek to', handleSeek) + + backend.on('set party nickname', nickname => { + let oldNickname = client.nickname + sharedSources.name = namePartySources(nickname) + client.nickname = nickname + client.sendCommand({code: 'set nickname', nickname, oldNickname}) + }) + + backend.on('QP: shuffle queue', queuePlayer => { + client.sendCommand({ + code: 'restore queue', + why: 'shuffle', + queuePlayer: queuePlayer.id, + tracks: queuePlayer.queueGrouplike.items.map(saveItemReference) + }) + }) + + backend.on('QP: toggle pause', queuePlayer => { + client.sendCommand({ + code: 'set pause', + queuePlayer: queuePlayer.id, + paused: queuePlayer.player.isPaused + }) + }) + + backend.on('QP: unqueue', (queuePlayer, topItem) => { + client.sendCommand({ + code: 'unqueue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem) + }) + }) + + backend.on('announce join party', () => { + client.sendCommand({ + code: 'announce join' + }) + }) + + backend.on('share with party', item => { + if (sharedSources.items.every(x => x[originalSymbol] !== item)) { + const serialized = serializePartySource(item) + const deserialized = deserializePartySource(serialized) + + deserialized[parentSymbol] = sharedSources + deserialized[originalSymbol] = item + + sharedSources.items.push(deserialized) + backend.sharedSourcesUpdated(client.socketId, sharedSources) + + updateRestoredTracksUsingPlaylists(backend, getPlaylistSources()) + + client.sendCommand({ + code: 'share with party', + item: serialized + }) + } + }) +} + +export function attachSocketServerToBackend(server, backend) { + // Unlike the function for attaching a backend to follow commands from a + // client (attachBackendToSocketClient), this function is minimalistic. + // It just sets the associated "canonical" backend. Actual logic for + // de/serialization lives in serialized-backend.js. + server.canonicalBackend = backend +} diff --git a/todo.txt b/todo.txt index 4c93789..da6c9e6 100644 --- a/todo.txt +++ b/todo.txt @@ -577,6 +577,43 @@ TODO: "BAM #45.3 - no" displays as "BAM #45.no" in the queue? Seems wrong! TODO: "Challenge 1 (Tricks)" etc in FP World 3 are "Challenge (Tricks)"! Bad. (Done!) +TODO: Tabber tab list should be accessible via tab (key). + +TODO: Show current index and number of tabs beside tabber tab list. + +TODO: The checks for "grouplike"/"track" have been super arbitrary for a long + time. It'd be nice to just store that info in a plain "type" property, + and tweak updateGroupFormat/etc to generate that! (Probably also the + crawlers themselves, but they aren't as big of a priority, and we should + support opening older playlist formats too.) + +TODO: Synchronize items that have been shared with the party upon a new client + joining. Should be next to (or part of) the initialize-backend command. + (Done!) + +TODO: We currently use a hack to access the original item in the context menu + for items in the party sources listing. This doesn't make, for example, + queuing by pressing enter on a track work. We should instead have a way + to specifically refer to the item "represented" by a line, rather than + the literal object it's associated with (i.e. the pseudo-track/group + shared in the sources array). + +TODO: Broadcast when a socket disconnects; show a log message and remove their + shared sources from the UI of other clients. + +TODO: Ditto for the server! Not (exclusively) as a broadcast message, though - + detect if the connection to the server is lost for any reason. + +TODO: The validation code for share-with-party sucks! It should be made into a + separate function which runs recursively, and should be used to validate + initialize-party too. + +TODO: Show debug log messages when validating a command fails! On both server + and client end. + +TODO: Naming a shared sources list should definitely happen in a function. + (Done!) + TODO: Pressing next track (N) on the last track should start the first track, if the queue is being looped. (Done!) @@ -696,3 +733,8 @@ TODO: Pressing escape while you've got items selected should deselect those still handy to not be locked out of stopping playback altogether. Alternative: clear the selection (without stopping playback) only if the cursor is currently on a selected item. + +TODO: GHOST BACKEND for socket server... the main thing is syncing duration + data. It sucks to have the player, like, actually be tied to a specific + instance of MPV or whatever, so we'd use a ~ghost player~ which supports + all the usual interfaces and lies about its current playback time. Yay! diff --git a/ui.js b/ui.js index f006a70..4a92ebe 100644 --- a/ui.js +++ b/ui.js @@ -18,6 +18,7 @@ import telc from 'tui-lib/util/telchars' import unic from 'tui-lib/util/unichars' import {getAllCrawlersForArg} from './crawlers.js' +import {originalSymbol} from './socket.js' import processSmartPlaylist from './smart-playlist.js' import UndoManager from './undo-manager.js' @@ -33,9 +34,12 @@ import { cloneGrouplike, collapseGrouplike, countTotalTracks, + findItemObject, flattenGrouplike, getCorrespondingFileForItem, getCorrespondingPlayableForFile, + getFlatTrackList, + getFlatGroupList, getItemPath, getNameWithoutTrackNumber, isGroup, @@ -48,8 +52,13 @@ import { shuffleOrderOfGroups, } from './playlist-utils.js' +import { + updateRestoredTracksUsingPlaylists, + getWaitingTrackData +} from './serialized-backend.js' + /* text editor features disabled because theyre very much incomplete and havent - * gotten much use from me or anyonea afaik! + * gotten much use from me or anyone afaik! const TuiTextEditor = require('tui-text-editor') */ @@ -178,6 +187,8 @@ export default class AppElement extends FocusElement { this.isPartyHost = false this.enableAutoDJ = false + // this.playlistSources = [] + this.config = Object.assign({ canControlPlayback: true, canControlQueue: true, @@ -187,7 +198,8 @@ export default class AppElement extends FocusElement { themeColor: 4, // blue seekToStartThreshold: 3, showTabberPane: true, - stopPlayingUponQuit: true + stopPlayingUponQuit: true, + showPartyControls: false }, config) // TODO: Move edit mode stuff to the backend! @@ -229,6 +241,18 @@ export default class AppElement extends FocusElement { }) */ + this.logPane = new Pane() + this.addChild(this.logPane) + + this.log = new Log() + this.logPane.addChild(this.log) + this.logPane.visible = false + + this.log.on('log message', () => { + this.logPane.visible = true + this.fixLayout() + }) + if (!this.config.showTabberPane) { this.tabberPane.visible = false } @@ -240,8 +264,6 @@ export default class AppElement extends FocusElement { this.metadataStatusLabel.visible = false this.tabberPane.addChild(this.metadataStatusLabel) - this.newGrouplikeListing() - this.queueListingElement = new QueueListingElement(this) this.setupCommonGrouplikeListingEvents(this.queueListingElement) this.queuePane.addChild(this.queueListingElement) @@ -261,6 +283,11 @@ export default class AppElement extends FocusElement { this.queueListingElement.on('select main listing', () => this.selected()) + if (this.config.showPartyControls) { + const sharedSourcesListing = this.newGrouplikeListing() + sharedSourcesListing.loadGrouplike(this.backend.sharedSourcesGrouplike) + } + this.playbackPane = new Pane() this.addChild(this.playbackPane) @@ -440,12 +467,15 @@ export default class AppElement extends FocusElement { bindListeners() { for (const key of [ - 'handlePlaying', + 'handlePlayingDetails', 'handleReceivedTimeData', 'handleProcessMetadataProgress', 'handleQueueUpdated', 'handleAddedQueuePlayer', 'handleRemovedQueuePlayer', + 'handleLogMessage', + 'handleGotSharedSources', + 'handleSharedSourcesUpdated', 'handleSetLoopQueueAtEnd' ]) { this[key] = this[key].bind(this) @@ -477,10 +507,6 @@ export default class AppElement extends FocusElement { PIE.on('seek back', () => PIE.queuePlayer.seekBack(5)) PIE.on('seek ahead', () => PIE.queuePlayer.seekAhead(5)) PIE.on('toggle pause', () => PIE.queuePlayer.togglePause()) - - queuePlayer.on('received time data', this.handleReceivedTimeData) - queuePlayer.on('playing', this.handlePlaying) - queuePlayer.on('queue updated', this.handleQueueUpdated) } removeQueuePlayerListenersAndUI(queuePlayer, updateUI = true) { @@ -507,24 +533,39 @@ export default class AppElement extends FocusElement { this.queuePlayersToActOn.splice(index, 1) } - queuePlayer.removeListener('receivedTimeData', this.handleReceivedTimeData) - queuePlayer.removeListener('playing', this.handlePlaying) - queuePlayer.removeListener('queue updated', this.handleQueueUpdated) queuePlayer.stopPlaying() } attachBackendListeners() { + // Backend-specialized listeners this.backend.on('processMetadata progress', this.handleProcessMetadataProgress) this.backend.on('added queue player', this.handleAddedQueuePlayer) this.backend.on('removed queue player', this.handleRemovedQueuePlayer) - this.backend.on('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) + this.backend.on('log message', this.handleLogMessage) + this.backend.on('got shared sources', this.handleGotSharedSources) + this.backend.on('shared sources updated', this.handleSharedSourcesUpdated) + this.backend.on('set loop queue at end', this.handleSetLoopQueueAtEnd) + + // Backend as queue player proxy listeners + this.backend.on('QP: playing details', this.handlePlayingDetails) + this.backend.on('QP: received time data', this.handleReceivedTimeData) + this.backend.on('QP: queue updated', this.handleQueueUpdated) } removeBackendListeners() { + // Backend-specialized listeners this.backend.removeListener('processMetadata progress', this.handleProcessMetadataProgress) this.backend.removeListener('added queue player', this.handleAddedQueuePlayer) this.backend.removeListener('removed queue player', this.handleRemovedQueuePlayer) - this.backend.removeListener('set-loop-queue-at-end', this.handleSetLoopQueueAtEnd) + this.backend.removeListener('log message', this.handleLogMessage) + this.backend.removeListener('got shared sources', this.handleGotSharedSources) + this.backend.removeListener('shared sources updated', this.handleSharedSourcesUpdated) + this.backend.removeListener('set loop queue at end', this.handleSetLoopQueueAtEnd) + + // Backend as queue player proxy listeners + this.backend.removeListener('QP: playing details', this.handlePlayingDetails) + this.backend.removeListener('QP: received time data', this.handleReceivedTimeData) + this.backend.removeListener('QP: queue updated', this.handleQueueUpdated) } handleAddedQueuePlayer(queuePlayer) { @@ -538,11 +579,32 @@ export default class AppElement extends FocusElement { } } + handleLogMessage(messageInfo) { + this.log.newLogMessage(messageInfo) + } + + handleGotSharedSources(socketId, sharedSources) { + for (const grouplikeListing of this.tabber.tabberElements) { + if (grouplikeListing.grouplike === this.backend.sharedSourcesGrouplike) { + grouplikeListing.loadGrouplike(this.backend.sharedSourcesGrouplike, false) + } + } + } + + handleSharedSourcesUpdated(socketId, partyGrouplike) { + for (const grouplikeListing of this.tabber.tabberElements) { + if (grouplikeListing.grouplike === partyGrouplike) { + grouplikeListing.loadGrouplike(partyGrouplike, false) + } + } + this.clearCachedMarkStatuses() + } + handleSetLoopQueueAtEnd() { this.updateQueueLengthLabel() } - async handlePlaying(track, oldTrack, startTime, queuePlayer) { + async handlePlayingDetails(queuePlayer, track, oldTrack, startTime) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateTrack() @@ -577,7 +639,7 @@ export default class AppElement extends FocusElement { } } - handleReceivedTimeData(timeData, oldTimeData, queuePlayer) { + handleReceivedTimeData(queuePlayer, timeData, oldTimeData) { const PIE = this.getPlaybackInfoElementForQueuePlayer(queuePlayer) if (PIE) { PIE.updateProgress() @@ -904,6 +966,10 @@ export default class AppElement extends FocusElement { this.queueListingElement.selectAndShow(item) } + shareWithParty(item) { + this.backend.shareWithParty(item) + } + replaceMark(items) { this.markGrouplike.items = items.slice(0) // Don't share the array! :) this.emitMarkChanged() @@ -968,7 +1034,11 @@ export default class AppElement extends FocusElement { } emitMarkChanged() { + this.clearCachedMarkStatuses() this.emit('mark changed') + } + + clearCachedMarkStatuses() { this.cachedMarkStatuses = new Map() this.scheduleDrawWithoutPropertyChange() } @@ -1370,6 +1440,32 @@ export default class AppElement extends FocusElement { }) } + const itemPath = getItemPath(item) + const [rootGroup, _partySources, sharedGroup] = itemPath + + // This is the hack mentioned in the todo!!!! + if (this.config.showPartyControls && rootGroup.isPartySources) { + const playlists = this.tabber.tabberElements + .map(grouplikeListing => getItemPath(grouplikeListing.grouplike)[0]) + .filter(root => !root.isPartySources) + + let possibleChoices = [] + if (item.downloaderArg) { + possibleChoices = getFlatTrackList({items: playlists}) + } else if (item.items) { + possibleChoices = getFlatGroupList({items: playlists}) + } + if (possibleChoices) { + item = findItemObject(item, possibleChoices) + } + + if (!item) { + return [ + {label: `(Couldn't find this in your music)`} + ] + } + } + // const hasNotesFile = !!getCorrespondingFileForItem(item, '.txt') const timestampsItem = this.hasTimestampsFile(item) && (this.timestampsExpanded(item, listing) ? {label: 'Collapse saved timestamps', action: () => this.collapseTimestamps(item, listing)} @@ -1421,29 +1517,35 @@ export default class AppElement extends FocusElement { // anyMarked && !this.isReal && {label: 'Paste', action: () => this.emit('paste')}, // No "above" or "elow" in the label because the "fake" item/row will be replaced (it'll disappear, since there'll be an item in the group) // {divider: true}, - canControlQueue && isPlayable(item) && {element: this.whereControl}, - canControlQueue && isGroup(item) && {element: this.orderControl}, - canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)}, - canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)}, - {divider: true}, - - canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))}, - canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))}, - canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))}, - isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)}, - isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)}, - // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)}, - // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)}, - canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, - isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)}, - {divider: true}, - - timestampsItem, - ...(item === this.markGrouplike - ? [{label: 'Deselect all', action: () => this.unmarkAll()}] + ...((this.config.showPartyControls && !rootGroup.isPartySources) + ? [ + {label: 'Share with party', action: () => this.shareWithParty(item)}, + {divider: true} + ] : [ - this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)}, - this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)} + canControlQueue && isPlayable(item) && {element: this.whereControl}, + canControlQueue && isGroup(item) && {element: this.orderControl}, + canControlQueue && isPlayable(item) && {label: 'Play!', action: emitControls(true)}, + canControlQueue && isPlayable(item) && {label: 'Queue!', action: emitControls(false)}, + {divider: true}, + canProcessMetadata && isGroup(item) && {label: 'Process metadata (new entries)', action: () => setTimeout(() => this.processMetadata(item, false))}, + canProcessMetadata && isGroup(item) && {label: 'Process metadata (reprocess)', action: () => setTimeout(() => this.processMetadata(item, true))}, + canProcessMetadata && isTrack(item) && {label: 'Process metadata', action: () => setTimeout(() => this.processMetadata(item, true))}, + isOpenable(item) && item.url.endsWith('.json') && {label: 'Open (JSON Playlist)', action: () => this.openSpecialOrThroughSystem(item)}, + isOpenable(item) && {label: 'Open (System)', action: () => this.openThroughSystem(item)}, + // !hasNotesFile && isPlayable(item) && {label: 'Create notes file', action: () => this.editNotesFile(item, true)}, + // hasNotesFile && isPlayable(item) && {label: 'Edit notes file', action: () => this.editNotesFile(item, true)}, + canControlQueue && isPlayable(item) && {label: 'Remove from queue', action: () => this.unqueue(item)}, + isTrack(item) && isQueued && {label: 'Reveal in queue', action: () => this.revealInQueue(item)}, + {divider: true}, + + timestampsItem, + ...(item === this.markGrouplike + ? [{label: 'Deselect all', action: () => this.unmarkAll()}] + : [ + this.getMarkStatus(item) !== 'unmarked' && {label: 'Remove from selection', action: () => this.unmarkItem(item)}, + this.getMarkStatus(item) !== 'marked' && {label: 'Add to selection', action: () => this.markItem(item)} + ]) ]) ] } @@ -1494,6 +1596,9 @@ export default class AppElement extends FocusElement { grouplike = await processSmartPlaylist(grouplike) + // this.playlistSources.push(grouplike) + // updateRestoredTracksUsingPlaylists(this.backend, this.playlistSources) + if (!this.tabber.currentElement || newTab && this.tabber.currentElement.grouplike) { const grouplikeListing = this.newGrouplikeListing() grouplikeListing.loadGrouplike(grouplike) @@ -1607,10 +1712,21 @@ export default class AppElement extends FocusElement { } */ + if (this.logPane.visible) { + this.logPane.w = leftWidth + this.logPane.h = 6 + this.log.fillParent() + this.log.fixAllLayout() + } + if (this.tabberPane.visible) { this.tabberPane.w = leftWidth this.tabberPane.y = bottomY this.tabberPane.h = topY - this.tabberPane.y + if (this.logPane.visible) { + this.tabberPane.h -= this.logPane.h + this.logPane.y = this.tabberPane.bottom + } /* if (this.textInfoPane.visible) { this.tabberPane.h -= this.textInfoPane.h @@ -4336,6 +4452,7 @@ class PlaybackInfoElement extends FocusElement { refreshTrackText(maxNameWidth = Infinity) { const { playingTrack } = this.queuePlayer + const waitingTrackData = getWaitingTrackData(this.queuePlayer) if (playingTrack) { this.currentTrack = playingTrack const { name } = playingTrack @@ -4347,6 +4464,11 @@ class PlaybackInfoElement extends FocusElement { this.progressBarLabel.text = '' this.progressTextLabel.text = '(Starting..)' this.timeData = {} + } else if (waitingTrackData) { + const { name } = waitingTrackData + this.clearInfoText() + this.trackNameLabel.text = name + this.progressTextLabel.text = '(Waiting to play, once found in playlist source.)' } else { this.clearInfoText() } @@ -5373,3 +5495,98 @@ class NotesTextEditor extends TuiTextEditor { } } */ + +class Log extends ListScrollForm { + constructor() { + super('vertical') + } + + newLogMessage(messageInfo) { + if (this.inputs.length === 10) { + this.removeInput(this.inputs[0]) + } + + if (messageInfo.mayCombine) { + // If a message is specified to "combine", it'll replace an immediately + // previous message of the same code and sender. + const previous = this.inputs[this.inputs.length - 1] + if ( + previous && + previous.info.code === messageInfo.code && + previous.info.sender === messageInfo.sender + ) { + // If the code and sender match, just remove the previous message. + // It'll be replaced by the one we're about to add! + this.removeInput(previous) + } + } + + const logMessage = new LogMessage(messageInfo) + this.addInput(logMessage) + this.fixLayout() + this.scrollToEnd() + this.emit('log message', logMessage) + return logMessage + } +} + +class LogMessage extends FocusElement { + constructor(info) { + super() + + this.info = info + + const { + text, + isVerbose = false + } = info + + this.label = new LogMessageLabel(text, isVerbose) + this.addChild(this.label) + } + + fixLayout() { + this.w = this.parent.contentW + this.label.w = this.contentW + this.h = this.label.h + } + + clicked(button) { + if (button === 'left') { + this.root.select(this) + return false + } + } +} + +class LogMessageLabel extends WrapLabel { + constructor(text, isVerbose = false) { + super(text) + + this.isVerbose = isVerbose + } + + writeTextTo(writable) { + const w = this.w + const lines = this.getWrappedLines() + for (let i = 0; i < lines.length; i++) { + const text = this.processFormatting(lines[i]) + writable.write(ansi.moveCursor(this.absTop + i, this.absLeft)) + writable.write(text) + const width = ansi.measureColumns(text) + if (width < w && this.textAttributes.length) { + writable.write(ansi.setAttributes([ansi.A_RESET, ...this.textAttributes])) + writable.write(' '.repeat(w - width)) + } + } + } + + set textAttributes(val) {} + + get textAttributes() { + return [ + this.parent.isSelected ? 40 : null, + this.isVerbose ? 2 : null + ].filter(x => x !== null) + } +} |