« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--backend.js193
-rw-r--r--general-util.js25
-rwxr-xr-xindex.js45
-rw-r--r--package-lock.json1084
-rw-r--r--package.json4
-rw-r--r--players.js15
-rw-r--r--playlist-utils.js324
-rw-r--r--serialized-backend.js228
-rw-r--r--socket.js992
-rw-r--r--todo.txt42
-rw-r--r--ui.js293
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)
+  }
+}