UNPKG

mpd2

Version:

Music Player Daemon client

359 lines (301 loc) 8.95 kB
'use strict' const net = require('net') const os = require('os') const fs = require('fs') const path = require('path') const EventEmitter = require('events').EventEmitter const assert = require('assert') const debug = require('debug')(require('../package.json').name) const { isError, MPDError } = require('./error') const { isString, isNonEmptyString, escapeArg, parseList, parseNestedList, parseListAndAccumulate, parseObject, normalizeKeys, autoparseValues } = require('./parsers') const { Command } = require('./command') const MPD_SENTINEL = /^(OK|ACK|list_OK)(.*)$/m const OK_MPD = /^OK MPD / class MPDClient extends EventEmitter { constructor (config) { super() this._config = config this._promiseQueue = [] this._buf = '' this._bufPos = 0 this._idleevts = {} this._disconnecting = false // bind to this client this.disconnect = this.disconnect.bind(this) this._receive = this._receive.bind(this) this._handleIdling = this._handleIdling.bind(this) this._triggerIdleEvents = this._triggerIdleEvents.bind(this) } static connect (config) { if (!config || typeof config !== 'object') { config = getDefaultConfig() debug('connect: using config %o', config) } // allow tilde shortcuts if connecting to a socket if (isString(config.path) && config.path.startsWith('~')) { config.path = config.path.replace(/^~/, os.homedir()) } return finalizeClientConnection( new MPDClient(config), net.connect(config)) } async sendCommand (command) { assert.ok(this.idling) const promise = this._enqueuePromise() this.stopIdling() this.send(command) this.setupIdling() return promise } async sendCommands (commandList) { const cmd = 'command_list_begin\n' + commandList.join('\n') + '\ncommand_list_end' return this.sendCommand(cmd) } stopIdling () { if (!this.idling) { return } this.idling = false this.send('noidle') } setupIdling () { if (this.idling) { debug('already idling') return } if (this._disconnecting) { debug('client is being disconnected, ignoring idling setup') return } this.idling = true this._enqueuePromise().then(this._handleIdling) this.send('idle') } send (data) { if (!this.socket.writable) { throw new MPDError('Not connected', 'ENOTCONNECTED') } debug('sending %s', data) this.socket.write(data + '\n') } disconnect () { this._disconnecting = true return new Promise((resolve) => { if (this.socket && this.socket.destroyed) { return resolve() } let _resolve = () => { if (resolve) { resolve() resolve = null } } this.socket.once('close', _resolve) this.socket.once('end', _resolve) this.socket.end() setTimeout(() => this.socket.destroy(), 32) }) } _enqueuePromise () { return new Promise((resolve, reject) => this._promiseQueue.push({ resolve, reject })) } _resolve (msg) { this._promiseQueue.shift().resolve(msg) } _reject (err) { this._promiseQueue.shift().reject(err) } _receive (data) { let matched this._buf += data let endPos = 0 while ((endPos = this._buf.indexOf('\n', this._bufPos)) > -1) { let line = this._buf.substring(this._bufPos, endPos) if ((matched = line.match(MPD_SENTINEL)) !== null) { let code = matched[1] let desc = matched[2] let msg = this._buf.substring(0, this._bufPos) code !== 'ACK' ? this._resolve(msg || code) // if empty msg, send back OK : this._reject(new MPDError(desc)) this._buf = this._buf.substring(endPos + 1) this._bufPos = 0 endPos = 0 } else { // handle binary responses "as-is" by forwarding // the buffer with the amount of expected bytes if (line.substring(0, 7) === 'binary:') { endPos += parseInt(line.substring(7)); } this._bufPos = endPos + 1; } } } _handleIdling (msg) { // store events and trigger with delay, // either a problem with MPD (not likely) // or this implementation; same events are // triggered multiple times (especially mixer) if (isNonEmptyString(msg)) { let msgs = msg.split('\n').filter(s => s.length > 9) for (let msg of msgs) { let name = msg.substring(9) this._idleevts[name] = true } } if (this._promiseQueue.length === 0) { this.idling = false this.setupIdling() } clearTimeout(this._idleevtsTID) this._idleevtsTID = setTimeout(this._triggerIdleEvents, 16) } _triggerIdleEvents () { for (let name in this._idleevts) { debug('triggering %s', name) this.emit(`system-${name}`) this.emit('system', name) } this._idleevts = {} } } /** * check that we're connected to MPD * and check for password requirements */ const finalizeClientConnection = (client, socket) => new Promise((resolve, reject) => { socket.setEncoding('utf8') socket.on('error', reject) let protoVersion let idleCheckTimeout let password = isNonEmptyString(client._config.password) ? client._config.password : false const onTimeout = () => { debug('socket timed out') try { socket.destroy() } catch (e) { debug('socket destroy failed') } client.emit('close') reject(new MPDError('Connection timed out', 'CONNECTION_TIMEOUT')) } const finalize = () => { debug('preparing client') Object.defineProperty( client, 'PROTOCOL_VERSION', { get: () => socket.destroyed ? undefined : protoVersion } ) if (password) { delete client._config.password } socket.removeListener('data', onData) socket.removeListener('timeout', onTimeout) socket.on('data', client._receive) socket.on('close', () => { debug('close') client.emit('close') }) client.socket = socket client.setupIdling() resolve(client) } const onData = data => { // expected MPD proto response if (!MPD_SENTINEL.test(data)) { debug('invalid server response %s', data) reject(new MPDError('Unexpected MPD service response', 'INVALIDMPDSERVICE', `got: '${data}'`)) return } // initial response with proto version if (OK_MPD.test(data) && !protoVersion) { protoVersion = data.split(OK_MPD)[1] debug('connected to MPD server, proto version: %s', protoVersion) // check for presence of the password if (password) { debug('sending password') socket.write(`password ${escapeArg(password)}\n`) return } } // check if there was an error (password / idle) const error = isError(data) if (error) { reject(error) socket.destroy() return } // do we need to test with the idle? if (!idleCheckTimeout) { debug('idle check') // set idle to test for the error for // in case MPD requires a password but // has not been set socket.write('idle\n') // idle does not respond, so if there // was no error, disable idle to get // the response idleCheckTimeout = setTimeout(() => { socket.write('noidle\n') }, 100) return } finalize() } socket.on('data', onData) socket.on('timeout', onTimeout) }) const getDefaultConfig = () => { const config = {} const timeout = Number(process.env.MPD_TIMEOUT) if (!Number.isNaN(timeout)) { config.timeout = timeout } const socket = [ process.env.MPD_HOST, process.env.XDG_RUNTIME_DIR ? path.join(process.env.XDG_RUNTIME_DIR, 'mpd', 'socket') : undefined ].find(candidate => candidate ? isSocket(candidate) : false) if (socket) { config.path = socket } else { config.host = process.env.MPD_HOST || 'localhost' config.port = process.env.MPD_PORT || 6600 } return config } const isSocket = socketPath => { if (typeof socketPath !== 'string' || socketPath.length === 0) { return } try { debug('default config: checking if %o is a socket', socketPath) if (fs.lstatSync(socketPath).isSocket()) { return socketPath } } catch (e) { } } MPDClient.MPDError = MPDError MPDClient.Command = Command MPDClient.cmd = Command.cmd MPDClient.parseList = parseList MPDClient.parseNestedList = parseNestedList MPDClient.parseListAndAccumulate = parseListAndAccumulate MPDClient.parseObject = parseObject MPDClient.normalizeKeys = normalizeKeys MPDClient.autoparseValues = autoparseValues module.exports = MPDClient module.exports.default = MPDClient