diff options
Diffstat (limited to 'socket.js')
-rw-r--r-- | socket.js | 992 |
1 files changed, 992 insertions, 0 deletions
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 +} |