« get me outta code hell

mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/socket.js
diff options
context:
space:
mode:
Diffstat (limited to 'socket.js')
-rw-r--r--socket.js1017
1 files changed, 1017 insertions, 0 deletions
diff --git a/socket.js b/socket.js
new file mode 100644
index 0000000..5c54bbc
--- /dev/null
+++ b/socket.js
@@ -0,0 +1,1017 @@
+// 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 'added queue player':
+          return (
+            typeof command.id === 'string'
+          )
+        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) {
+                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 'added queue player':
+        actionmsg = `created a new playback 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)
+            attachPlaybackBackendListeners()
+            // 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': {
+            // All this code looks very scary???
+            /*
+            // 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)
+            */
+            silenceEvents(QP, ['set pause'], () => QP.setPause(command.paused))
+            return
+          }
+          case 'added queue player': {
+            silenceEvents(backend, ['added queue player'], () => {
+              const QP = backend.addQueuePlayer()
+              QP.id = command.id
+            })
+            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('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
+      })
+    }
+  })
+
+  backend.on('set party nickname', nickname => {
+    let oldNickname = client.nickname
+    sharedSources.name = namePartySources(nickname)
+    client.nickname = nickname
+    client.sendCommand({code: 'set nickname', nickname, oldNickname})
+  })
+
+  function attachPlaybackBackendListeners() {
+    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
+        })
+      }
+    })
+
+    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('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('added queue player', (queuePlayer) => {
+      client.sendCommand({
+        code: 'added queue player',
+        id: queuePlayer.id,
+      })
+    })
+  }
+}
+
+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
+}