UNPKG

airtunes

Version:

an AirTunes v2 implementation: stream wirelessly to audio devices.

539 lines (432 loc) 13 kB
var net = require('net'), crypto = require('crypto'), events = require('events'), util = require('util'), fs = require('fs'), config = require('./config.js'), audioOut = require('./audio_out.js'), nu = require('./num_util.js'); var OPTIONS = 0, ANNOUNCE = 1, SETUP = 2, RECORD = 3, SETVOLUME = 4, PLAYING = 5, TEARDOWN = 6, CLOSED = 7, SETDAAP = 8, SETART = 9; function Client(volume, password) { events.EventEmitter.call(this); this.status = OPTIONS; this.socket = null; this.cseq = 0; this.announceId = null; this.activeRemote = nu.randomInt(9); this.dacpId = nu.randomHex(8); this.session = null; this.timeout = null; this.volume = volume; this.password = password; this.passwordTried = false; this.requireEncryption = false; this.trackInfo = null; this.artwork = null; this.artworkContentType = null; this.callback = null; this.controlPort = null; this.timingPort = null; } util.inherits(Client, events.EventEmitter); exports.Client = Client; Client.prototype.startHandshake = function(udpServers, host, port) { var self = this; this.startTimeout(); this.controlPort = udpServers.control.port; this.timingPort = udpServers.timing.port; this.socket = net.connect(port, host, function() { self.clearTimeout(); self.sendNextRequest(); }); var blob = ''; this.socket.on('data', function(data) { self.clearTimeout(); /* * I wish I could use node's HTTP parser for this... * I assume that all responses have empty bodies. */ data = data.toString(); var endIndex = data.indexOf('\r\n\r\n'); if(endIndex < 0) { blob += data; return; } endIndex += 4; // the end of \r\n\r\n blob += data.substring(0, endIndex); self.processData(blob); blob = data.substring(endIndex); }); this.socket.on('error', function(err) { self.socket = null; if(err.code === 'ECONNREFUSED') self.cleanup('connection_refused'); else self.cleanup('rtsp_socket', err.code); }); this.socket.on('end', function() { self.cleanup('disconnected'); }); }; Client.prototype.startTimeout = function() { var self = this; this.timeout = setTimeout(function() { self.cleanup('timeout'); }, config.rtsp_timeout); }; Client.prototype.clearTimeout = function() { if(this.timeout !== null) { clearTimeout(this.timeout); this.timeout = null; } }; Client.prototype.teardown = function() { if(this.status === CLOSED) { this.emit('end', 'stopped'); return; } this.status = TEARDOWN; this.sendNextRequest(); }; Client.prototype.setVolume = function(volume, callback) { if(this.status !== PLAYING) return; this.volume = volume; this.callback = callback; this.status = SETVOLUME; this.sendNextRequest(); }; Client.prototype.setTrackInfo = function(name, artist, album, callback) { if(this.status !== PLAYING) return; this.trackInfo = { name: name, artist: artist, album: album }; this.status = SETDAAP; this.callback = callback; this.sendNextRequest(); }; Client.prototype.setArtwork = function(art, contentType, callback) { if(this.status !== PLAYING) return; if (typeof contentType == 'function') { callback = contentType; contentType = null; } if (typeof art == 'string') { var self = this; if (contentType === null) { var ext = art.slice(-4); if (ext == ".jpg" || ext == "jpeg") { contentType = "image/jpeg"; } else if (ext == ".png") { contentType = "image/png"; } else if (ext == ".gif") { contentType = "image/gif"; } else { return self.cleanup('unknown_art_file_ext'); } } return fs.readFile(art, function(err, data) { if (err !== null) { return self.cleanup('invalid_art_file'); } self.setArtwork(data, contentType, callback); }); } if (contentType === null) return this.cleanup('no_art_content_type'); this.artworkContentType = contentType; this.artwork = art; this.status = SETART; this.callback = callback; this.sendNextRequest(); }; Client.prototype.nextCSeq = function() { this.cseq += 1; return this.cseq; }; Client.prototype.cleanup = function(type, msg) { this.emit('end', type, msg); this.status = CLOSED; this.trackInfo = null; this.artwork = null; this.artworkContentType = null; this.callback = null; this.removeAllListeners(); if(this.timeout) { clearTimeout(this.timeout); this.timeout = null; } if(this.socket) { this.socket.destroy(); this.socket = null; } }; function parseResponse(blob) { var response = {}, lines = blob.split('\r\n'); var codeRes = /(\w+)\/(\S+) (\d+) (.*)/.exec(lines[0]); if(!codeRes) { response.code = 599; response.status = 'UNEXPECTED ' + lines[0]; return response; } response.code = parseInt(codeRes[3], 10); response.status = codeRes[4]; var headers = {}; lines.slice(1).forEach(function(line) { var res = /([^:]+): (.*)/.exec(line); if(!res) return; headers[res[1]] = res[2]; }); response.headers = headers; return response; } function md5(str) { var md5sum = crypto.createHash('md5'); md5sum.update(str); return md5sum.digest('hex').toUpperCase(); } Client.prototype.makeHead = function(method, uri, di) { var head = method + ' ' + uri + ' RTSP/1.0\r\n' + 'CSeq: ' + this.nextCSeq() + '\r\n' + 'User-Agent: ' + config.user_agent + '\r\n' + 'DACP-ID: ' + this.dacpId + '\r\n' + 'Client-Instance: ' + this.dacpId + '\r\n' + (this.session ? 'Session: ' + this.session + '\r\n' : '') + 'Active-Remote: ' + this.activeRemote + '\r\n'; if(di) { var ha1 = md5(di.username + ':' + di.realm + ':' + di.password); var ha2 = md5(method + ':' + uri); var diResponse = md5(ha1 + ':' + di.nonce + ':' + ha2); head += 'Authorization: Digest ' + 'username="' + di.username + '", ' + 'realm="' + di.realm + '", ' + 'nonce="' + di.nonce + '", ' + 'uri="' + uri + '", ' + 'response="' + diResponse + '"\r\n'; } return head; } Client.prototype.makeHeadWithURL = function(method, digestInfo) { return this.makeHead(method, 'rtsp://' + this.socket.address().address + '/' + this.announceId, digestInfo); } Client.prototype.makeRtpInfo = function() { var nextSeq = audioOut.lastSeq + 1; var rtpSyncTime = nextSeq*config.frames_per_packet + 2*config.sampling_rate; return 'RTP-Info: seq=' + nextSeq + ';rtptime=' + rtpSyncTime + '\r\n'; }; Client.prototype.sendNextRequest = function(di) { var request = '', body = ''; switch(this.status) { case OPTIONS: request += this.makeHead('OPTIONS', '*', di); request += 'Apple-Challenge: SdX9kFJVxgKVMFof/Znj4Q\r\n\r\n'; break; case ANNOUNCE: this.announceId = nu.randomInt(8); body = 'v=0\r\n' + 'o=iTunes ' + this.announceId +' 0 IN IP4 ' + this.socket.address().address + '\r\n' + 's=iTunes\r\n' + 'c=IN IP4 ' + this.socket.address().address + '\r\n' + 't=0 0\r\n' + 'm=audio 0 RTP/AVP 96\r\n' + 'a=rtpmap:96 AppleLossless\r\n' + 'a=fmtp:96 352 0 16 40 10 14 2 255 0 0 44100\r\n'; if (this.requireEncryption) { body += 'a=rsaaeskey:' + config.rsa_aeskey_base64 + '\r\n' + 'a=aesiv:' + config.iv_base64 + '\r\n'; } request += this.makeHeadWithURL('ANNOUNCE', di); request += 'Content-Type: application/sdp\r\n' + 'Content-Length: ' + body.length + '\r\n\r\n'; request += body; break; case SETUP: request += this.makeHeadWithURL('SETUP', di); request += 'Transport: RTP/AVP/UDP;unicast;interleaved=0-1;mode=record;' + 'control_port=' + this.controlPort + ';' + 'timing_port=' + this.timingPort + '\r\n\r\n'; break; case RECORD: request += this.makeHeadWithURL('RECORD', di); request += this.makeRtpInfo() + 'Range: npt=0-\r\n\r\n'; break; case SETVOLUME: var attenuation = this.volume === 0.0 ? -144.0 : (-30.0)*(100 - this.volume)/100.0; body = 'volume: ' + attenuation + '\r\n'; request += this.makeHeadWithURL('SET_PARAMETER', di); request += 'Content-Type: text/parameters\r\n' + 'Content-Length: ' + body.length + '\r\n\r\n'; request += body; break; case SETDAAP: var name = this.daapEncode('minm', this.trackInfo.name); var artist = this.daapEncode('asar', this.trackInfo.artist); var album = this.daapEncode('asal', this.trackInfo.album); var daapInfo = this.daapEncodeList('mlit', name, artist, album); var head = this.makeHeadWithURL('SET_PARAMETER', di); head += this.makeRtpInfo(); head += 'Content-Type: application/x-dmap-tagged\r\n' + 'Content-Length: ' + daapInfo.length + '\r\n\r\n'; var buf = new Buffer(head.length); buf.write(head, 0, head.length, 'ascii'); request = Buffer.concat([buf, daapInfo]); break; case SETART: var head = this.makeHeadWithURL('SET_PARAMETER', di); head += this.makeRtpInfo(); head += 'Content-Type: ' + this.artworkContentType + '\r\n' + 'Content-Length: ' + this.artwork.length + '\r\n\r\n'; var buf = new Buffer(head.length); buf.write(head, 0, head.length, 'ascii'); request = Buffer.concat([buf, this.artwork]); break; case TEARDOWN: this.socket.end(this.makeHead('TEARDOWN', '', di) + '\r\n'); this.cleanup('stopped'); // return here since the socket is closed return; default: return; } this.startTimeout(); this.socket.write(request); }; Client.prototype.daapEncodeList = function(field) { var values = Array.prototype.slice.call(arguments); values.shift(); var value = Buffer.concat(values); var buf = new Buffer(field.length + 4); buf.write(field, 0, field.length, 'ascii'); buf.writeUInt32BE(value.length, field.length); return Buffer.concat([buf, value]); }; Client.prototype.daapEncode = function(field, value) { var buf = new Buffer(field.length + value.length + 4); buf.write(field, 0, field.length, 'ascii'); buf.writeUInt32BE(value.length, field.length); buf.write(value, field.length + 4, value.length, 'ascii'); return buf; }; Client.prototype.parsePorts = function(headers) { function parsePort(name, transport) { var re = new RegExp(name + '=(\\d+)'); var res = re.exec(transport); return res ? parseInt(res[1]) : null; } var transport = headers['Transport'], rtspConfig = { audioLatency: parseInt(headers['Audio-Latency']), requireEncryption: this.requireEncryption }, names = ['server_port', 'control_port', 'timing_port']; for(var i = 0; i < names.length; i++) { var name = names[i]; var port = parsePort(name, transport); if(!port) { this.cleanup('parse_ports', transport); return false; } else rtspConfig[name] = port; } this.emit('config', rtspConfig); return true; } function parseAuthenticate(auth, field) { var re = new RegExp(field + '="([^"]+)"'), res = re.exec(auth); return res ? res[1] : null; } Client.prototype.processData = function(blob) { var response = parseResponse(blob), headers = response.headers; if(response.code === 401) { if(!this.password) { this.cleanup('no_password'); return; } if(this.passwordTried) { this.cleanup('bad_password'); return; } else this.passwordTried = true; var auth = headers['WWW-Authenticate']; var di = { realm: parseAuthenticate(auth, 'realm'), nonce: parseAuthenticate(auth, 'nonce'), username: 'Radioline', password: this.password }; this.sendNextRequest(di); return; } if(response.code === 453) { this.cleanup('busy'); return; } if(response.code !== 200) { this.cleanup(response.status); return; } // password was accepted (or not needed) this.passwordTried = false; switch(this.status) { case OPTIONS: /* * Devices like Apple TV and Zeppelin Air do not support encryption. * Only way of checking that: they do not reply to Apple-Challenge */ if(headers['Apple-Response']) this.requireEncryption = true; this.status = ANNOUNCE; break; case ANNOUNCE: this.status = SETUP; break; case SETUP: this.status = RECORD; this.session = headers['Session']; this.parsePorts(headers); break; case RECORD: this.status = SETVOLUME; this.emit('ready'); break; case SETVOLUME: this.status = PLAYING; break; case SETDAAP: this.status = PLAYING; break; case SETART: this.status = PLAYING; break; } if (this.callback != null) { this.callback(); } this.sendNextRequest(); }