From 2c7e3c8fb279f20da3d1b4f5610e65dc43a22ac2 Mon Sep 17 00:00:00 2001 From: Florrie Date: Sat, 11 Jul 2020 16:22:01 -0300 Subject: support queue controls over socket clients --- backend.js | 22 +++++- general-util.js | 14 ++++ playlist-utils.js | 128 +++++++++++++++++-------------- serialized-backend.js | 107 +++++++++++++++++++------- socket.js | 206 ++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 380 insertions(+), 97 deletions(-) diff --git a/backend.js b/backend.js index 418c2eb..c59dfdf 100644 --- a/backend.js +++ b/backend.js @@ -165,6 +165,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. @@ -173,9 +174,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 @@ -227,6 +231,7 @@ class QueuePlayer extends EventEmitter { } } + this.emit('distribute-queue', topItem, {how, rangeEnd}) this.emitQueueUpdated() } @@ -271,6 +276,7 @@ class QueuePlayer extends EventEmitter { } recursivelyUnqueueTracks(topItem) + this.emit('unqueue', topItem) this.emitQueueUpdated() return focusItem @@ -288,6 +294,7 @@ class QueuePlayer extends EventEmitter { items.splice(index) } + this.emit('clear-queue-past', track) this.emitQueueUpdated() } @@ -304,6 +311,7 @@ class QueuePlayer extends EventEmitter { items.splice(startIndex, endIndex - startIndex) } + this.emit('clear-queue-up-to', track) this.emitQueueUpdated() } @@ -336,6 +344,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() } @@ -344,6 +353,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() } @@ -563,7 +573,11 @@ class QueuePlayer extends EventEmitter { } seekBack(seconds) { - this.time -= seconds + if (this.time < seconds) { + this.time = 0 + } else { + this.time -= seconds + } this.player.seekBack(seconds) this.emit('seek-back', +seconds) } diff --git a/general-util.js b/general-util.js index b767a1b..b4491de 100644 --- a/general-util.js +++ b/general-util.js @@ -335,3 +335,17 @@ export async function parseOptions(options, optionDescriptorMap) { } parseOptions.handleDashless = Symbol() + +export function silenceEvents(emitter, eventsToSilence, callback) { + const oldEmit = emitter.emit + + emitter.emit = function(event, ...data) { + if (!eventsToSilence.includes(event)) { + oldEmit.apply(emitter, [event, ...data]) + } + } + + callback() + + emitter.emit = oldEmit +} diff --git a/playlist-utils.js b/playlist-utils.js index f58a9e8..7c742ae 100644 --- a/playlist-utils.js +++ b/playlist-utils.js @@ -187,15 +187,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) { @@ -847,20 +862,17 @@ export function getPathScore(path1, path2) { return scores.reduce((a, b) => a < b ? a : b) } -export function findTrackObject(referenceData, sourcePlaylist, flattenedSourcePlaylist = null) { - // Finds the track object in the source playlist which most closely resembles +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 - // track objects when reloading a playlist (see serialized-backend.js). It's - // also usable in synchronizing the identity of tracks across linked clients + // 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). - // - // NB: This function is many times more efficient if you pass a preemptively - // flattened version of the source playlist in as well! - // Reference data includes track NAME, track SOURCE (downloaderArg), and - // track PATH (names of parent groups). Specifics of how existing track - // objects are determined to resemble this data are laid out next to the - // relevant implementation code. + // Reference data includes item NAME, item SOURCE (downloaderArg), 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? @@ -868,41 +880,37 @@ export function findTrackObject(referenceData, sourcePlaylist, flattenedSourcePl // tracks *is*, and in considering those I lean towards "no" here, but // it's probably worth looking at more in the future. (TM.) - function getTrackPathScore(track) { + function getItemPathScore(item) { if (!referenceData.path) { return null } const path1 = referenceData.path.slice() - const path2 = getItemPath(track).slice(0, -1).map(group => group.name) + const path2 = getItemPath(item).slice(0, -1).map(group => group.name) return getPathScore(path1, path2) } - if (!flattenedSourcePlaylist) { - flattenedSourcePlaylist = flattenGrouplike(sourcePlaylist) - } - - // The only tracks which will be considered at all are those which match at + // The only items which will be considered at all are those which match at // least one of the reference name/source. - const baselineResemble = flattenedSourcePlaylist.items.filter(track => - track.name === referenceData.name || - track.downloaderArg === referenceData.downloaderArg) + const baselineResemble = possibleChoices.filter(item => + item.name === referenceData.name || + item.downloaderArg && item.downloaderArg === referenceData.downloaderArg) - // If no track matches the baseline conditions for resemblance at all, + // 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 track was found, or creating a new track object + // 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 tracks resemble the reference data; these will - // be used as the factors in calculating which track resembles closest. - const reasons = baselineResemble.map(track => ({ - track, - nameMatches: track.name === referenceData.name, - sourceMatches: track.downloaderArg === referenceData.downloaderArg, - pathScore: getTrackPathScore(track) + // 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 => ({ + item, + nameMatches: item.name === referenceData.name, + sourceMatches: item.downloaderArg && item.downloaderArg === referenceData.downloaderArg, + pathScore: getItemPathScore(item) })) // TODO: The algorithm for determining which track matches closest is @@ -933,7 +941,7 @@ export function findTrackObject(referenceData, sourcePlaylist, flattenedSourcePl mostResembles = reasons[0] } - return mostResembles.track + return mostResembles.item } /* @@ -941,23 +949,27 @@ console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C'])) console.log(getPathScore(['A', 'B', 'C'], ['A', 'B', 'C', 'D'])) console.log(getPathScore(['A', 'B', 'C', 'E'], ['A', 'B', 'C'])) console.log(getPathScore(['W', 'X'], ['Y', 'Z'])) -console.log(findTrackObject( - {name: 'T', downloaderArg: 'foo', path: ['A', 'B', 'C']}, - updateGroupFormat({items: [ - {id: 1, name: 'T'}, - {id: 2, name: 'T'}, - {id: 3, name: 'T'}, - // {id: 4, name: 'T', downloaderArg: 'foo'}, - {id: 5, name: 'T'}, - {id: 6, name: 'Y', downloaderArg: 'foo'}, - // {name: 'A', items: [ - // {name: 'B', items: [ - // {name: 'C', items: [ - // {name: 'T'} - // ]}, - // {name: 'T'} - // ]} - // ]} - ]}) +console.log(findItemObject( + // {name: 'T', downloaderArg: 'foo', path: ['A', 'B', 'C']}, + {name: 'B'}, + // getFlatTrackList( + getFlatGroupList( + updateGroupFormat({items: [ + {id: 1, name: 'T'}, + {id: 2, name: 'T'}, + {id: 3, name: 'T'}, + // {id: 4, name: 'T', downloaderArg: 'foo'}, + {id: 5, name: 'T'}, + {id: 6, name: 'Y', downloaderArg: 'foo'}, + {name: 'A', items: [ + {name: 'B', items: [ + {name: 'C', items: [ + {name: 'T'} + ]}, + {name: 'T'} + ]} + ]} + ]}) + ) )) */ diff --git a/serialized-backend.js b/serialized-backend.js index b43fa49..7ae5e9d 100644 --- a/serialized-backend.js +++ b/serialized-backend.js @@ -22,8 +22,12 @@ 'use strict' import { - findTrackObject, + isGroup, + isTrack, + findItemObject, flattenGrouplike, + getFlatGroupList, + getFlatTrackList, getItemPath, } from './playlist-utils.js' @@ -40,25 +44,11 @@ function getPlayerInfo(queuePlayer) { } 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 => ({ id: QP.id, - playingTrack: referenceTrack(QP.playingTrack), - queuedTracks: QP.queueGrouplike.items.map(referenceTrack), + playingTrack: saveItemReference(QP.playingTrack), + queuedTracks: QP.queueGrouplike.items.map(saveItemReference), pauseNextTrack: QP.pauseNextTrack, playerInfo: getPlayerInfo(QP) })) @@ -77,11 +67,7 @@ export async function restoreBackend(backend, data) { QP.id = qpData.id - QP.queueGrouplike.items = qpData.queuedTracks.map(refData => ({ - [referenceDataSymbol]: refData, - name: refData.name, - downloaderArg: refData.downloaderArg - })) + QP.queueGrouplike.items = qpData.queuedTracks.map(refData => restoreNewItem(refData)) QP.player.setVolume(qpData.playerInfo.volume) QP.player.setLoop(qpData.playerInfo.isLooping) @@ -122,7 +108,7 @@ export function updateRestoredTracksUsingPlaylists(backend, 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 + // 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. // @@ -140,8 +126,7 @@ export function updateRestoredTracksUsingPlaylists(backend, playlists) { // 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) + const possibleChoices = getFlatTrackList({items: playlists}) for (const QP of backend.queuePlayers) { let playingDataToRestore @@ -155,7 +140,7 @@ export function updateRestoredTracksUsingPlaylists(backend, playlists) { } if (playingDataToRestore) { - const found = findTrackObject(playingDataToRestore, combinedPlaylist, flattenedPlaylist) + const found = findItemObject(playingDataToRestore, possibleChoices) if (found) { restorePlayingTrack(QP, found, qpData.playerInfo || getPlayerInfo(QP)) } @@ -167,13 +152,81 @@ export function updateRestoredTracksUsingPlaylists(backend, playlists) { return track } - return findTrackObject(refData, combinedPlaylist, flattenedPlaylist) || 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 diff --git a/socket.js b/socket.js index 0ac1a74..be337e2 100644 --- a/socket.js +++ b/socket.js @@ -15,10 +15,16 @@ import * as net from 'node:net' import { restoreBackend, + restoreNewItem, saveBackend, + saveItemReference, updateRestoredTracksUsingPlaylists, } from './serialized-backend.js' +import { + silenceEvents +} from './general-util.js' + function serializeCommandToData(command) { // Turn a command into a string/buffer that can be sent over a socket. return JSON.stringify(command) @@ -30,6 +36,38 @@ function deserializeDataToCommand(data) { return JSON.parse(data) } +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. @@ -52,6 +90,51 @@ function validateCommand(command) { // clients too. case 'client': switch (command.code) { + case 'clear-queue': + return typeof command.queuePlayer === 'string' + case 'clear-queue-past': + case 'clear-queue-up-to': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) + ) + 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 '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)) + ) case 'seek-to': return ( typeof command.queuePlayer === 'string' && @@ -64,6 +147,11 @@ function validateCommand(command) { ) case 'status': return typeof command.status === 'string' + case 'unqueue': + return ( + typeof command.queuePlayer === 'string' && + isItemRef(command.topItem) + ) } break } @@ -119,7 +207,7 @@ export function makeSocketServer() { sender: 'server', code: 'set-pause', queuePlayer: QP.id, - paused: false + paused: QP.player.isPaused })) } } @@ -130,9 +218,11 @@ export function makeSocketServer() { return } - // Relay the data to client sockets. + // Relay the command to client sockets besides the sender. + + const otherSockets = sockets.filter(s => s !== socket) - for (const socket of sockets) { + for (const socket of otherSockets) { socket.write(JSON.stringify(command)) } } @@ -231,22 +321,98 @@ export function attachBackendToSocketClient(backend, client, { ) switch (command.code) { + 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.topItem, getPlaylistSources()) + )) + return + case 'clear-queue-up-to': + if (QP) silenceEvents(QP, ['clear-queue-up-to'], () => QP.clearQueueUpTo( + restoreNewItem(command.topItem, getPlaylistSources()) + )) + return + case 'distribute-queue': + if (QP) silenceEvents(QP, ['distribute-queue'], () => QP.distributeQueue( + restoreNewItem(command.topItem, getPlaylistSources()), + { + how: command.opts.how, + rangeEnd: command.opts.rangeEnd + } + )) + 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.queueGrouplike.items = command.tracks.map(refData => restoreNewItem(refData)) + // TODO: target just the one queue player. hacks = illegal + updateRestoredTracksUsingPlaylists(backend, getPlaylistSources()) + } case 'seek-to': - if (QP) QP.seekTo(command.time) + if (QP) silenceEvents(QP, ['seek-to'], () => QP.seekTo(command.time)) return case 'set-pause': - if (QP) QP.setPause(command.paused) + if (QP) silenceEvents(QP, ['set-pause'], () => QP.setPause(command.paused)) + return + case 'unqueue': + if (QP) silenceEvents(QP, ['unqueue'], () => QP.unqueue( + restoreNewItem(command.topItem, getPlaylistSources()) + )) return } } } }) - backend.on('toggle-pause', queuePlayer => { + backend.on('clear-queue', queuePlayer => { client.sendCommand({ - code: 'set-pause', + code: 'clear-queue', + queuePlayer: queuePlayer.id + }) + }) + + backend.on('clear-queue-past', (queuePlayer, topItem) => { + client.sendCommand({ + code: 'clear-queue-past', queuePlayer: queuePlayer.id, - paused: queuePlayer.player.isPaused + topItem: saveItemReference(topItem) + }) + }) + + backend.on('clear-queue-up-to', (queuePlayer, topItem) => { + client.sendCommand({ + code: 'clear-queue-up-to', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem) + }) + }) + + backend.on('distribute-queue', (queuePlayer, topItem, opts) => { + client.sendCommand({ + code: 'distribute-queue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem), + opts + }) + }) + + backend.on('queue', (queuePlayer, topItem, afterItem, opts) => { + client.sendCommand({ + code: 'queue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem), + afterItem: saveItemReference(afterItem), + opts }) }) @@ -261,6 +427,30 @@ export function attachBackendToSocketClient(backend, client, { backend.on('seek-ahead', handleSeek) backend.on('seek-back', handleSeek) backend.on('seek-to', handleSeek) + + backend.on('shuffle-queue', queuePlayer => { + client.sendCommand({ + code: 'restore-queue', + queuePlayer: queuePlayer.id, + tracks: queuePlayer.queueGrouplike.items.map(saveItemReference) + }) + }) + + backend.on('toggle-pause', queuePlayer => { + client.sendCommand({ + code: 'set-pause', + queuePlayer: queuePlayer.id, + paused: queuePlayer.player.isPaused + }) + }) + + backend.on('unqueue', (queuePlayer, topItem) => { + client.sendCommand({ + code: 'unqueue', + queuePlayer: queuePlayer.id, + topItem: saveItemReference(topItem) + }) + }) } export function attachSocketServerToBackend(server, backend) { -- cgit 1.3.0-6-gf8a5