« get me outta code hell

basic working backend save/restore & socket server - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFlorrie <towerofnix@gmail.com>2020-07-10 11:28:34 -0300
committer(quasar) nebula <qznebula@protonmail.com>2024-05-16 18:37:00 -0300
commit90cad535c470fffa5c34c48737e44c1641416f0d (patch)
tree6b46582ea6d4a5585065d3a50ad3805ed047a4fb
parent3f17023373f3fa26ccb556ab1f83215b2a134e8c (diff)
basic working backend save/restore & socket server
Backend save/restore code (living in serialized-backend.js) has been
well tested and shouldn't need much change going forward. Now we get to
begin working on the actual synchronized-over-socket-server commands!
-rw-r--r--backend.js7
-rwxr-xr-xindex.js31
-rw-r--r--serialized-backend.js180
-rw-r--r--socket.js176
-rw-r--r--ui.js16
5 files changed, 407 insertions, 3 deletions
diff --git a/backend.js b/backend.js
index 4142026..048a6c5 100644
--- a/backend.js
+++ b/backend.js
@@ -63,6 +63,7 @@ class QueuePlayer extends EventEmitter {
     this.queueGrouplike = {name: 'Queue', isTheQueue: true, items: []}
     this.pauseNextTrack = false
     this.queueEndMode = 'end' // end, loop, shuffle
+
     this.playedTrackToEnd = false
     this.timeData = null
 
@@ -353,7 +354,7 @@ class QueuePlayer extends EventEmitter {
   }
 
 
-  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')
     }
@@ -403,7 +404,9 @@ class QueuePlayer extends EventEmitter {
       this.emit('playing', this.playingTrack, oldTrack, startTime, this)
 
       await this.player.kill()
-      if (this.playedTrackToEnd) {
+      if (forceStartPaused) {
+        this.player.setPause(true)
+      } else if (this.playedTrackToEnd) {
         this.player.setPause(this.pauseNextTrack)
         this.pauseNextTrack = false
         this.playedTrackToEnd = false
diff --git a/index.js b/index.js
index 7632844..0553808 100755
--- a/index.js
+++ b/index.js
@@ -4,11 +4,22 @@
 
 import {getPlayer} from './players.js'
 import {parseOptions} from './general-util.js'
-import {getItemPathString} from './playlist-utils.js'
 import Backend from './backend.js'
 import setupClient from './client.js'
 import TelnetServer from './telnet.js'
 
+import {
+  attachBackendToSocketClient,
+  attachSocketServerToBackend,
+  makeSocketServer,
+  makeSocketClient,
+} from './socket.js'
+
+import {
+  getItemPathString,
+  updatePlaylistFormat,
+} from './playlist-utils.js'
+
 import {CommandLineInterface} from 'tui-lib/util/interfaces'
 import * as ansi from 'tui-lib/util/ansi'
 
@@ -52,6 +63,8 @@ async function main() {
 
     'player-options': {type: 'series'},
     'stress-test': {type: 'flag'},
+    'socket-client': {type: 'value'},
+    'socket-server': {type: 'flag'},
     'telnet-server': {type: 'flag'},
     'skip-config-file': {type: 'flag'},
     'config-file': {type: 'value'},
@@ -164,6 +177,22 @@ async function main() {
     appElement.attachAsServerHost(telnetServer)
   }
 
+  let socketServer
+  if (options['socket-server']) {
+    socketServer = makeSocketServer()
+    attachSocketServerToBackend(socketServer, backend)
+    socketServer.listen(1255)
+  }
+
+  let socketClient
+  if (options['socket-client']) {
+    socketClient = makeSocketClient()
+    attachBackendToSocketClient(backend, socketClient, {
+      getPlaylistSources: () => appElement.playlistSources
+    })
+    socketClient.socket.connect(1255)
+  }
+
   if (options['stress-test']) {
     await loadPlaylistPromise
 
diff --git a/serialized-backend.js b/serialized-backend.js
new file mode 100644
index 0000000..4ea3019
--- /dev/null
+++ b/serialized-backend.js
@@ -0,0 +1,180 @@
+// 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 {
+  findTrackObject,
+  flattenGrouplike,
+  getItemPath,
+} from './playlist-utils.js'
+
+const referenceDataSymbol = Symbol('Restored reference data')
+
+function getPlayerInfo(queuePlayer) {
+  const { player, timeData } = queuePlayer
+  return {
+    time: timeData && timeData.curSecTotal,
+    isLooping: player.isLooping,
+    isPaused: player.isPaused,
+    volume: player.volume
+  }
+}
+
+export function saveBackend(backend) {
+  function referenceTrack(track) {
+    if (track) {
+      // This is the same format used as referenceData in findTrackObject
+      // (in playlist-utils.js).
+      return {
+        name: track.name,
+        downloaderArg: track.downloaderArg,
+        path: getItemPath(track).slice(0, -1).map(group => group.name)
+      }
+    } else {
+      return null
+    }
+  }
+
+  return {
+    queuePlayers: backend.queuePlayers.map(QP => ({
+      playingTrack: referenceTrack(QP.playingTrack),
+      queuedTracks: QP.queueGrouplike.items.map(referenceTrack),
+      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.queueGrouplike.items = qpData.queuedTracks.map(refData => ({
+        [referenceDataSymbol]: refData,
+        name: refData.name,
+        downloaderArg: refData.downloaderArg
+      }))
+
+      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, true)
+  QP.once('received time data', () => {
+    if (QP.playingTrack === playedTrack) {
+      QP.player.seekTo(playerInfo.time)
+      if (!playerInfo.isPaused) {
+        QP.player.togglePause()
+      }
+    }
+  })
+}
+
+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 findTrackObject, 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 combinedPlaylist = {items: playlists}
+  const flattenedPlaylist = flattenGrouplike(combinedPlaylist)
+
+  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 = findTrackObject(playingDataToRestore, combinedPlaylist, flattenedPlaylist)
+      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 findTrackObject(refData, combinedPlaylist, flattenedPlaylist) || track
+    })
+
+    QP.emit('queue updated')
+  }
+}
+
+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..0e8759b
--- /dev/null
+++ b/socket.js
@@ -0,0 +1,176 @@
+// 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.
+
+'use strict' // single quotes & no semicolons time babey
+
+import EventEmitter from 'node:events'
+import * as net from 'node:net'
+
+import {
+  restoreBackend,
+  saveBackend,
+  updateRestoredTracksUsingPlaylists,
+} from './serialized-backend.js'
+
+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 JSON.parse(data)
+}
+
+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-backend':
+          return typeof command.backend === 'object'
+      }
+      break
+    case 'client':
+      break
+  }
+
+  return false
+}
+
+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 sockets = []
+
+  server.canonicalBackend = null
+
+  function receivedData(data) {
+    // Parse data as a command and validate it. If invalid, drop this data.
+
+    let command
+    try {
+      command = deserializeDataToCommand(data)
+    } catch (error) {
+      return
+    }
+
+    if (!validateCommand(command)) {
+      return
+    }
+
+    // Relay the data to client sockets.
+
+    for (const socket of sockets) {
+      socket.write(command)
+    }
+  }
+
+  server.on('connection', socket => {
+    sockets.push(socket)
+
+    socket.on('close', () => {
+      if (sockets.includes(socket)) {
+        sockets.splice(sockets.indexOf(socket), 1)
+      }
+    })
+
+    socket.on('data', receivedData)
+
+    socket.write(JSON.stringify({
+      sender: 'server',
+      code: 'initialize-backend',
+      backend: saveBackend(server.canonicalBackend)
+    }))
+  })
+
+  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.sendCommand = function(command) {
+    const data = serializeCommandToData(command)
+    client.socket.write(data)
+  }
+
+  client.socket.on('data', data => {
+    // 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(data)
+    } catch (error) {
+      return
+    }
+
+    if (!validateCommand(command)) {
+      return
+    }
+
+    client.emit('command', command)
+  })
+
+  return client
+}
+
+export function attachBackendToSocketClient(backend, client, {
+  getPlaylistSources
+}) {
+  // All actual logic for instances of the mtui backend interacting with each
+  // other through commands lives here.
+
+  client.on('command', async command => {
+    switch (command.sender) {
+      case 'server':
+        switch (command.code) {
+          case 'initialize-backend':
+            await restoreBackend(backend, command.backend)
+            // TODO: does this need to be called here?
+            updateRestoredTracksUsingPlaylists(backend, getPlaylistSources())
+            break
+        }
+        break
+    }
+  })
+}
+
+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/ui.js b/ui.js
index b784688..dd1123b 100644
--- a/ui.js
+++ b/ui.js
@@ -49,6 +49,11 @@ 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!
 const TuiTextEditor = require('tui-text-editor')
@@ -179,6 +184,8 @@ export default class AppElement extends FocusElement {
     this.isPartyHost = false
     this.enableAutoDJ = false
 
+    this.playlistSources = []
+
     this.config = Object.assign({
       canControlPlayback: true,
       canControlQueue: true,
@@ -1588,6 +1595,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)
@@ -4430,6 +4440,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
@@ -4441,6 +4452,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()
     }