« get me outta code hell

serialized-backend.js - mtui - Music Text User Interface - user-friendly command line music player
about summary refs log tree commit diff
path: root/serialized-backend.js
blob: a3f02fa83884dd78119e0b5d0d2b25b04291ae5e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
// 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'

const {
  isGroup,
  isTrack,
  findItemObject,
  flattenGrouplike,
  getFlatGroupList,
  getFlatTrackList,
  getItemPath
} = require('./playlist-utils')

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
  }
}

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)
    }))
  }
}

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, true)
  QP.once('received time data', () => {
    if (QP.playingTrack === playedTrack) {
      QP.player.seekTo(playerInfo.time)
      if (!playerInfo.isPaused) {
        QP.player.togglePause()
      }
    }
  })
}

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')
  }
}

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
  }
}

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
    }
  }
}

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
}

Object.assign(module.exports, {
  saveBackend,
  restoreBackend,
  updateRestoredTracksUsingPlaylists,
  saveItemReference,
  restoreNewItem,
  getWaitingTrackData
})