├── .gitignore ├── .npmignore ├── lib ├── airplay.js └── airplay │ ├── browser.js │ ├── client.js │ └── device.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .gitignore 3 | .git/ 4 | .git* 5 | -------------------------------------------------------------------------------- /lib/airplay.js: -------------------------------------------------------------------------------- 1 | exports.Browser = require('./airplay/browser').Browser; 2 | exports.createBrowser = function() { 3 | return new exports.Browser(); 4 | }; 5 | 6 | exports.Device = require('./airplay/device').Device; 7 | exports.connect = function(host, port, opt_pass) { 8 | // TODO: connect 9 | throw 'not yet implemented'; 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airplay", 3 | "description": "Apple AirPlay client library", 4 | "version": "0.0.3", 5 | "author": "Ben Vanik ", 6 | "contributors": [], 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/benvanik/node-airplay.git" 10 | }, 11 | "keywords": [ 12 | "apple", 13 | "mac", 14 | "media", 15 | "airplay" 16 | ], 17 | "directories": { 18 | "lib": "./lib/airplay" 19 | }, 20 | "main": "./lib/airplay", 21 | "dependencies": { 22 | "plist": ">=0.2.1", 23 | "mdns": ">=0.0.7" 24 | }, 25 | "scripts": { 26 | }, 27 | "engines": { 28 | "node": ">= 0.5.x" 29 | }, 30 | "devDependencies": {} 31 | } 32 | -------------------------------------------------------------------------------- /lib/airplay/browser.js: -------------------------------------------------------------------------------- 1 | var events = require('events'); 2 | var mdns = require('mdns'); 3 | var util = require('util'); 4 | 5 | var Device = require('./device').Device; 6 | 7 | var Browser = function() { 8 | var self = this; 9 | 10 | this.devices_ = {}; 11 | this.nextDeviceId_ = 0; 12 | 13 | this.browser_ = mdns.createBrowser(mdns.tcp('airplay')); 14 | this.browser_.on('serviceUp', function(info, flags) { 15 | var device = self.findDeviceByInfo_(info); 16 | if (!device) { 17 | device = new Device(self.nextDeviceId_++, info); 18 | self.devices_[device.id] = device; 19 | device.on('ready', function() { 20 | self.emit('deviceOnline', device); 21 | }); 22 | device.on('close', function() { 23 | delete self.devices_[device.id]; 24 | self.emit('deviceOffline', device); 25 | }); 26 | } 27 | }); 28 | this.browser_.on('serviceDown', function(info, flags) { 29 | var device = self.findDeviceByInfo_(info); 30 | if (device) { 31 | device.close(); 32 | } 33 | }); 34 | }; 35 | util.inherits(Browser, events.EventEmitter); 36 | exports.Browser = Browser; 37 | 38 | Browser.prototype.findDeviceByInfo_ = function(info) { 39 | for (var deviceId in this.devices_) { 40 | var device = this.devices_[deviceId]; 41 | if (device.matchesInfo(info)) { 42 | return device; 43 | } 44 | } 45 | return null; 46 | }; 47 | 48 | Browser.prototype.getDevices = function() { 49 | var devices = []; 50 | for (var deviceId in this.devices_) { 51 | var device = this.devices_[deviceId]; 52 | if (device.isReady()) { 53 | devices.push(device); 54 | } 55 | } 56 | return devices; 57 | }; 58 | 59 | Browser.prototype.getDeviceById = function(deviceId) { 60 | var device = this.devices_[deviceId]; 61 | if (device && device.isReady()) { 62 | return device; 63 | } 64 | return null; 65 | }; 66 | 67 | Browser.prototype.start = function() { 68 | this.browser_.start(); 69 | }; 70 | 71 | Browser.prototype.stop = function() { 72 | this.browser_.stop(); 73 | }; 74 | -------------------------------------------------------------------------------- /lib/airplay/client.js: -------------------------------------------------------------------------------- 1 | var buffer = require('buffer'); 2 | var events = require('events'); 3 | var net = require('net'); 4 | var util = require('util'); 5 | 6 | var Client = function(host, port, user, pass, callback) { 7 | var self = this; 8 | 9 | this.host_ = host; 10 | this.port_ = port; 11 | this.user_ = user; 12 | this.pass_ = pass; 13 | 14 | this.responseWaiters_ = []; 15 | 16 | this.socket_ = net.createConnection(port, host); 17 | this.socket_.on('connect', function() { 18 | self.responseWaiters_.push({ 19 | callback: callback 20 | }); 21 | self.socket_.write( 22 | 'GET /playback-info HTTP/1.1\n' + 23 | 'User-Agent: MediaControl/1.0\n' + 24 | 'Content-Length: 0\n' + 25 | '\n'); 26 | }); 27 | 28 | this.socket_.on('data', function(data) { 29 | var res = self.parseResponse_(data.toString()); 30 | //util.puts(util.inspect(res)); 31 | 32 | var waiter = self.responseWaiters_.shift(); 33 | if (waiter.callback) { 34 | waiter.callback(res); 35 | } 36 | }); 37 | }; 38 | util.inherits(Client, events.EventEmitter); 39 | exports.Client = Client; 40 | 41 | Client.prototype.close = function() { 42 | if (this.socket_) { 43 | this.socket_.destroy(); 44 | } 45 | this.socket_ = null; 46 | }; 47 | 48 | Client.prototype.parseResponse_ = function(res) { 49 | // Look for HTTP response: 50 | // HTTP/1.1 200 OK 51 | // Some-Header: value 52 | // Content-Length: 427 53 | // \n 54 | // body (427 bytes) 55 | 56 | var header = res; 57 | var body = ''; 58 | var splitPoint = res.indexOf('\r\n\r\n'); 59 | if (splitPoint != -1) { 60 | header = res.substr(0, splitPoint); 61 | body = res.substr(splitPoint + 4); 62 | } 63 | 64 | // Normalize header \r\n -> \n 65 | header = header.replace(/\r\n/g, '\n'); 66 | 67 | // Peel off status 68 | var status = header.substr(0, header.indexOf('\n')); 69 | var statusMatch = status.match(/HTTP\/1.1 ([0-9]+) (.+)/); 70 | header = header.substr(status.length + 1); 71 | 72 | // Parse headers 73 | var allHeaders = {}; 74 | var headerLines = header.split('\n'); 75 | for (var n = 0; n < headerLines.length; n++) { 76 | var headerLine = headerLines[n]; 77 | var key = headerLine.substr(0, headerLine.indexOf(':')); 78 | var value = headerLine.substr(key.length + 2); 79 | allHeaders[key] = value; 80 | } 81 | 82 | // Trim body? 83 | return { 84 | statusCode: parseInt(statusMatch[1]), 85 | statusReason: statusMatch[2], 86 | headers: allHeaders, 87 | body: body 88 | }; 89 | }; 90 | 91 | Client.prototype.issue_ = function(req, body, callback) { 92 | if (!this.socket_) { 93 | util.puts('client not connected'); 94 | return; 95 | } 96 | 97 | req.headers = req.headers || {}; 98 | req.headers['User-Agent'] = 'MediaControl/1.0'; 99 | req.headers['Content-Length'] = body ? buffer.Buffer.byteLength(body) : 0; 100 | req.headers['Connection'] = 'keep-alive'; 101 | 102 | var allHeaders = ''; 103 | for (var key in req.headers) { 104 | allHeaders += key + ': ' + req.headers[key] + '\n'; 105 | } 106 | 107 | var text = 108 | req.method + ' ' + req.path + ' HTTP/1.1\n' + 109 | allHeaders + '\n'; 110 | if (body) { 111 | text += body; 112 | } 113 | 114 | this.responseWaiters_.push({ 115 | callback: callback 116 | }); 117 | this.socket_.write(text); 118 | }; 119 | 120 | Client.prototype.get = function(path, callback) { 121 | var req = { 122 | method: 'GET', 123 | path: path, 124 | }; 125 | this.issue_(req, null, callback); 126 | }; 127 | 128 | Client.prototype.post = function(path, body, callback) { 129 | var req = { 130 | method: 'POST', 131 | path: path, 132 | }; 133 | this.issue_(req, body, callback); 134 | }; 135 | -------------------------------------------------------------------------------- /lib/airplay/device.js: -------------------------------------------------------------------------------- 1 | var events = require('events'); 2 | var plist = require('plist'); 3 | var util = require('util'); 4 | 5 | var Client = require('./client').Client; 6 | 7 | var Device = function(id, info, opt_readyCallback) { 8 | var self = this; 9 | 10 | this.id = id; 11 | this.info_ = info; 12 | this.serverInfo_ = null; 13 | this.ready_ = false; 14 | 15 | var host = info.host; 16 | var port = info.port; 17 | var user = 'Airplay'; 18 | var pass = ''; 19 | this.client_ = new Client(host, port, user, pass, function() { 20 | // TODO: support passwords 21 | 22 | self.client_.get('/server-info', function(res) { 23 | plist.parse(res.body, function(err, obj) { 24 | var el = obj[0]; 25 | self.serverInfo_ = { 26 | deviceId: el.deviceid, 27 | features: el.features, 28 | model: el.model, 29 | protocolVersion: el.protovers, 30 | sourceVersion: el.srcvers 31 | }; 32 | }); 33 | 34 | self.makeReady_(opt_readyCallback); 35 | }); 36 | }); 37 | }; 38 | util.inherits(Device, events.EventEmitter); 39 | exports.Device = Device; 40 | 41 | Device.prototype.isReady = function() { 42 | return this.ready_; 43 | }; 44 | 45 | Device.prototype.makeReady_ = function(opt_readyCallback) { 46 | this.ready_ = true; 47 | if (opt_readyCallback) { 48 | opt_readyCallback(this); 49 | } 50 | this.emit('ready'); 51 | }; 52 | 53 | Device.prototype.close = function() { 54 | if (this.client_) { 55 | this.client_.close(); 56 | } 57 | this.client_ = null; 58 | this.ready_ = false; 59 | 60 | this.emit('close'); 61 | }; 62 | 63 | Device.prototype.getInfo = function() { 64 | var info = this.info_; 65 | var serverInfo = this.serverInfo_; 66 | return { 67 | id: this.id, 68 | name: info.serviceName, 69 | deviceId: info.host, 70 | features: serverInfo.features, 71 | model: serverInfo.model, 72 | slideshowFeatures: [], 73 | supportedContentTypes: [] 74 | }; 75 | }; 76 | 77 | Device.prototype.getName = function() { 78 | return this.info_.serviceName; 79 | }; 80 | 81 | Device.prototype.matchesInfo = function(info) { 82 | for (var key in info) { 83 | if (this.info_[key] != info[key]) { 84 | return false; 85 | } 86 | } 87 | return true; 88 | }; 89 | 90 | Device.prototype.default = function(callback) { 91 | if (callback) { 92 | callback(this.getInfo()); 93 | } 94 | }; 95 | 96 | Device.prototype.status = function(callback) { 97 | this.client_.get('/playback-info', function(res) { 98 | if (res) { 99 | plist.parse(res.body, function(err, obj) { 100 | var el = obj[0]; 101 | var result = { 102 | duration: el.duration, 103 | position: el.position, 104 | rate: el.rate, 105 | playbackBufferEmpty: el.playbackBufferEmpty, 106 | playbackBufferFull: el.playbackBufferFull, 107 | playbackLikelyToKeepUp: el.playbackLikelyToKeepUp, 108 | readyToPlay: el.readyToPlay, 109 | loadedTimeRanges: el.loadedTimeRanges, 110 | seekableTimeRanges: el.seekableTimeRanges 111 | }; 112 | if (callback) { 113 | callback(result); 114 | } 115 | }); 116 | } else { 117 | if (callback) { 118 | callback(null); 119 | } 120 | } 121 | }); 122 | }; 123 | 124 | Device.prototype.authorize = function(req, callback) { 125 | // TODO: implement authorize 126 | if (callback) { 127 | callback(null); 128 | } 129 | }; 130 | 131 | Device.prototype.play = function(content, start, callback) { 132 | var body = 133 | 'Content-Location: ' + content + '\n' + 134 | 'Start-Position: ' + start + '\n'; 135 | this.client_.post('/play', body, function(res) { 136 | if (callback) { 137 | callback(res ? {} : null); 138 | } 139 | }); 140 | }; 141 | 142 | Device.prototype.stop = function(callback) { 143 | this.client_.post('/stop', null, function(res) { 144 | if (callback) { 145 | callback(res ? {} : null); 146 | } 147 | }); 148 | }; 149 | 150 | Device.prototype.scrub = function(position, callback) { 151 | this.client_.post('/scrub?position=' + position, null, function(res) { 152 | if (callback) { 153 | callback(res ? {} : null); 154 | } 155 | }); 156 | }; 157 | 158 | Device.prototype.reverse = function(callback) { 159 | this.client_.post('/reverse', null, function(res) { 160 | if (callback) { 161 | callback(res ? {} : null); 162 | } 163 | }) 164 | }; 165 | 166 | Device.prototype.rate = function(value, callback) { 167 | this.client_.post('/rate?value=' + value, null, function(res) { 168 | if (callback) { 169 | callback(res ? {} : null); 170 | } 171 | }) 172 | }; 173 | 174 | Device.prototype.volume = function(value, callback) { 175 | this.client_.post('/volume?value=' + value, null, function(res) { 176 | if (callback) { 177 | callback(res ? {} : null); 178 | } 179 | }) 180 | }; 181 | 182 | Device.prototype.photo = function(req, callback) { 183 | // TODO: implement photo 184 | if (callback) { 185 | callback(null); 186 | } 187 | }; 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | node-airplay -- AirPlay client library for node.js 2 | ==================================== 3 | 4 | node-airplay is a client library for Apple's 5 | [AirPlay](http://en.wikipedia.org/wiki/AirPlay) remote playback protocol. 6 | It implements a simple AirPlay device browser using mdns and command interface. 7 | 8 | Currently supported features: 9 | 10 | * AirPlay device discovery 11 | * Support for audio and video playback 12 | 13 | Coming soon (maybe): 14 | 15 | * Photo playback 16 | * Robust error handling 17 | * Better device information formatting (supported features/etc) 18 | 19 | ## Quickstart 20 | 21 | npm install airplay 22 | node 23 | > var browser = require('airplay').createBrowser(); 24 | > browser.on('deviceOnline', function(device) { 25 | device.play('http://host/somevideo.mp4', 0); 26 | }); 27 | > browser.start(); 28 | 29 | ## Installation 30 | 31 | With [npm](http://npmjs.org): 32 | 33 | npm install airplay 34 | 35 | From source: 36 | 37 | cd ~ 38 | git clone https://benvanik@github.com/benvanik/node-airplay.git 39 | npm link node-airplay/ 40 | 41 | node-airplay depends on both 42 | [node-plist](https://github.com/TooTallNate/node-plist) and 43 | [node_mdns](https://github.com/agnat/node_mdns). Unfortunately 44 | node_mdns is woefully out of date and has required many tweaks to get working, 45 | resulting in [a fork](https://github.com/benvanik/node_mdns). 46 | 47 | If you're running node on FreeBSD (or maybe Linux) you may get errors during 48 | install about a missing dns_sd.h file. If so, install the Apple mDNS SDK: 49 | 50 | wget http://www.opensource.apple.com/tarballs/mDNSResponder/mDNSResponder-522.1.11.tar.gz 51 | tar zxvf mDNSResponder-522.11.tar.gz 52 | cd mDNSResponder-333.10/mDNSPosix/ 53 | sudo gmake os=freebsd install 54 | 55 | ## API 56 | 57 | ### Browser 58 | 59 | The browser is a discovery service that can be run to automatically detect the 60 | AirPlay-compatiable devices on the local network(s). Try only to create one 61 | browser per node instance, and if it's no longer needed stop it. 62 | 63 | Create a browser using the `createBrowser` method: 64 | 65 | var browser = require('airplay').createBrowser(); 66 | 67 | Attach to the browser events to track device discovery: 68 | 69 | browser.on('deviceOnline', function(device) { 70 | console.log('device online: ' + device.id); 71 | }); 72 | browser.on('deviceOffline', function(device) { 73 | console.log('device offline: ' + device.id); 74 | }); 75 | 76 | Start or stop the discovery process: 77 | 78 | browser.start(); 79 | browser.stop(); 80 | 81 | If you are running a server you can use the built-in device list instead of 82 | maintaining your own via the events: 83 | 84 | function myHandler() { 85 | var devices = browser.getDevices(); 86 | console.log(devices); 87 | } 88 | 89 | ### Device 90 | 91 | A device instance represents a single AirPlay device on the local network. 92 | Devices are created either through the discovery process or by direct 93 | connection. Each device has only a single control channel, and all methods are 94 | asynchronous. 95 | 96 | Obtain devices using the browser API: 97 | 98 | // Get all ready devices 99 | var allDevices = browser.getDevices(); 100 | // Grab a device to play with 101 | var device = allDevices[0]; 102 | 103 | *TODO* At some point, you'll be able to connect directly: 104 | 105 | var device = require('airplay').connect(deviceHost); 106 | device.on('ready', function() { 107 | // Ready to accept commands 108 | }); 109 | 110 | If you are done with the device, close the connection (note that this will stop 111 | any playback): 112 | 113 | device.close(); 114 | 115 | Issue various device control calls. All calls are asynchronous and have an 116 | optional callback that provides the result - for most, it's an empty object if 117 | the call was successful and null if the call failed. 118 | 119 | // Get the current playback status 120 | device.getStatus(function(res) { 121 | // res = { 122 | // duration: number, -- in seconds 123 | // position: number, -- in seconds 124 | // rate: number, -- 0 = paused, 1 = playing 125 | // ... 126 | // } 127 | // or, if nothing is playing, res = {} 128 | }); 129 | 130 | // Play the given content (audio/video/etc) 131 | var content = 'http://host/content.mp4'; 132 | var startPosition = 0; // in seconds 133 | device.play(content, startPosition, function(res) { 134 | if (res) { 135 | // playing 136 | } else { 137 | // failed to start playback 138 | } 139 | }); 140 | 141 | // Stop playback and return to the main menu 142 | device.stop(); 143 | 144 | // Seek to the given offset in the media (if seek is supported) 145 | var position = 500; // in seconds 146 | device.scrub(position); 147 | 148 | // Reverse playback direction (rewind) 149 | // NOTE: may not be supported 150 | device.reverse(); 151 | 152 | // Change the playback rate 153 | // NOTE: only 0 and 1 seem to be supported for most media types 154 | var rate = 0; // 0 = pause, 1 = resume 155 | device.rate(rate); 156 | 157 | // Adjust playback volume 158 | // NOTE: may not be supported 159 | device.volume(value); 160 | --------------------------------------------------------------------------------