├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | os: 3 | - linux 4 | - osx 5 | node_js: 6 | - '7' 7 | - '6' 8 | - '5' 9 | - '4' 10 | - '0.12' 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Thomas Watson Steen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # airplay-protocol 2 | 3 | A low level protocol wrapper on top of the AirPlay HTTP API used to 4 | connect to an Apple TV. 5 | 6 | **For a proper AirPlay client, see 7 | [airplayer](https://github.com/watson/airplayer) instead.** 8 | 9 | Currently only the video API is implemented. 10 | 11 | [![Build status](https://travis-ci.org/watson/airplay-protocol.svg?branch=master)](https://travis-ci.org/watson/airplay-protocol) 12 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://github.com/feross/standard) 13 | 14 | ## Installation 15 | 16 | ``` 17 | npm install airplay-protocol --save 18 | ``` 19 | 20 | ## Example Usage 21 | 22 | ```js 23 | var AirPlay = require('airplay-protocol') 24 | 25 | var airplay = new AirPlay('apple-tv.local') 26 | 27 | airplay.play('http://example.com/video.m4v', function (err) { 28 | if (err) throw err 29 | 30 | airplay.playbackInfo(function (err, res, body) { 31 | if (err) throw err 32 | console.log('Playback info:', body) 33 | }) 34 | }) 35 | ``` 36 | 37 | ## API 38 | 39 | ### `new AirPlay(host[, port])` 40 | 41 | Initiate a connection to a specific AirPlay server given a host or IP 42 | address and a port. If no port is given, the default port 7000 is used. 43 | 44 | Returns an instance of the AirPlay object. 45 | 46 | ```js 47 | var AirPlay = require('airplay-protocol') 48 | 49 | var airplay = new AirPlay('192.168.0.42', 7000) 50 | ``` 51 | 52 | ### Event: `event` 53 | 54 | ```js 55 | function (event) {} 56 | ``` 57 | 58 | Emitted every time the AirPlay server sends an event. Events can hold 59 | different types of data, but will among other things be used to send 60 | updates to the playback state. 61 | 62 | Example event object indicating the state of the playback have changed: 63 | 64 | ```js 65 | { 66 | category: 'video', 67 | params: { 68 | uuid: 'D90C289F-DE6A-480C-A741-1DA92CEEE8C3-40-00000004654E2487' 69 | }, 70 | sessionID: 3, 71 | state: 'loading' 72 | } 73 | ``` 74 | 75 | The `event.params` property can potentially hold a lot more data than 76 | shown in this example. 77 | 78 | Example event object indicating an update to the access log: 79 | 80 | ```js 81 | { 82 | params: { 83 | uuid: '96388EC8-05C8-4BC4-A8EB-E9B6FCEB1A55-41-000000135E436A63' 84 | }, 85 | sessionID: 0, 86 | type: 'accessLogChanged' 87 | } 88 | ``` 89 | 90 | ### `airplay.state` 91 | 92 | Property holding the latest playback state emitted by the `event` event. 93 | Will be `null` if no `event` event have been emitted yet. 94 | 95 | Possible states: `loading`, `playing`, `paused` or `stopped`. 96 | 97 | ### `airplay.serverInfo(callback)` 98 | 99 | Get the AirPlay server info. 100 | 101 | Arguments: 102 | 103 | - `callback` - Will be called when the request have been processed by 104 | the AirPlay server. The first argument is an optional Error object. 105 | The second argument is an instance of [`http.IncomingMessage`][1] and 106 | the third argument is a parsed plist object containing the server info 107 | 108 | ### `airplay.play(url[, position][, callback])` 109 | 110 | Start video playback. 111 | 112 | Arguments: 113 | 114 | - `url` - The URL to play 115 | - `position` (optional) - A floating point number between `0` and `1` 116 | where `0` represents the begining of the video and `1` the end. 117 | Defaults to `0` 118 | - `callback` (optional) - Will be called when the request have been 119 | processed by the AirPlay server. The first argument is an optional 120 | Error object. The second argument is an instance of 121 | [`http.IncomingMessage`][1] 122 | 123 | ### `airplay.scrub(callback)` 124 | 125 | Retrieve the current playback position. 126 | 127 | Arguments: 128 | 129 | - `callback` - Will be called when the request have been processed by 130 | the AirPlay server. The first argument is an optional Error object. 131 | The second argument is an instance of [`http.IncomingMessage`][1] and 132 | the third argument is the current playback position 133 | 134 | ### `airplay.scrub(position[, callback])` 135 | 136 | Seek to an arbitrary location in the video. 137 | 138 | Arguments: 139 | 140 | - `position` - A float value representing the location in seconds 141 | - `callback` (optional) - Will be called when the request have been 142 | processed by the AirPlay server. The first argument is an optional 143 | Error object. The second argument is an instance of 144 | [`http.IncomingMessage`][1] 145 | 146 | ### `airplay.rate(speed[, callback])` 147 | 148 | Change the playback rate. 149 | 150 | Arguments: 151 | 152 | - `speed` - A float value representing the playback rate: 0 is paused, 1 153 | is playing at the normal speed 154 | - `callback` (optional) - Will be called when the request have been 155 | processed by the AirPlay server. The first argument is an optional 156 | Error object. The second argument is an instance of 157 | [`http.IncomingMessage`][1] 158 | 159 | ### `airplay.pause([callback])` 160 | 161 | Pause playback. 162 | 163 | Alias for `airplay.rate(0, callback)`. 164 | 165 | ### `airplay.resume([callback])` 166 | 167 | Resume playback. 168 | 169 | Alias for `airplay.rate(1, callback)`. 170 | 171 | ### `airplay.stop([callback])` 172 | 173 | Stop playback. 174 | 175 | Arguments: 176 | 177 | - `callback` (optional) - Will be called when the request have been 178 | processed by the AirPlay server. The first argument is an optional 179 | Error object. The second argument is an instance of 180 | [`http.IncomingMessage`][1] 181 | 182 | ### `airplay.playbackInfo(callback)` 183 | 184 | Retrieve playback informations such as position, duration, rate, 185 | buffering status and more. 186 | 187 | Arguments: 188 | 189 | - `callback` - Will be called when the request have been processed by 190 | the AirPlay server. The first argument is an optional Error object. 191 | The second argument is an instance of [`http.IncomingMessage`][1] and 192 | the third argument is a parsed plist object containing the playback info 193 | 194 | ### `airplay.property(name, callback)` 195 | 196 | Get playback property. 197 | 198 | Arguments: 199 | 200 | - `name` - The name of the property to get 201 | - `callback` - Will be called when the request have been processed by 202 | the AirPlay server. The first argument is an optional Error object. 203 | The second argument is an instance of [`http.IncomingMessage`][1] and 204 | the third argument is a parsed plist object containing the property 205 | 206 | ### `airplay.property(name, value[, callback])` 207 | 208 | Set playback property. 209 | 210 | Arguments: 211 | 212 | - `name` - The name of the property to set 213 | - `value` - The plist object to set 214 | - `callback` (optional) - Will be called when the request have been 215 | processed by the AirPlay server. The first argument is an optional 216 | Error object. The second argument is an instance of 217 | [`http.IncomingMessage`][1] 218 | 219 | ### `airplay.destroy()` 220 | 221 | Destroy the reverse-http server set up to receive AirPlay events. 222 | 223 | ## License 224 | 225 | MIT 226 | 227 | [1]: https://nodejs.org/api/http.html#http_class_http_incomingmessage 228 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var util = require('util') 4 | var http = require('http') 5 | var EventEmitter = require('events').EventEmitter 6 | var concat = require('concat-stream') 7 | var plist = require('plist') 8 | var bplistEncode = require('bplist-creator') 9 | var bplistDecode = require('bplist-parser').parseBuffer 10 | var reverseHttp = require('reverse-http') 11 | 12 | var USER_AGENT = 'iTunes/11.0.2' 13 | var noop = function () {} 14 | 15 | module.exports = AirPlay 16 | 17 | util.inherits(AirPlay, EventEmitter) 18 | 19 | function AirPlay (host, port) { 20 | if (!(this instanceof AirPlay)) return new AirPlay(host, port) 21 | 22 | EventEmitter.call(this) 23 | 24 | this.host = host 25 | this.port = port || 7000 26 | this.state = null 27 | this._rserver = null 28 | this._agent = new http.Agent({ 29 | keepAlive: true, 30 | maxSockets: 1 31 | }) 32 | } 33 | 34 | AirPlay.prototype.close = function close (cb) { 35 | if (this._rserver) this._rserver.close(cb) 36 | } 37 | 38 | AirPlay.prototype.destroy = function destroy () { 39 | if (this._rserver) this._rserver.destroy() 40 | this._agent.destroy() 41 | } 42 | 43 | AirPlay.prototype.serverInfo = function serverInfo (cb) { 44 | this._get('/server-info', cb) 45 | } 46 | 47 | AirPlay.prototype.play = function play (url, position, cb) { 48 | if (typeof position === 'function') return this.play(url, 0, position) 49 | 50 | this._startReverse() 51 | 52 | var body = 'Content-Location: ' + url + '\n' + 53 | 'Start-Position: ' + position + '\n' 54 | 55 | this._post('/play', body, cb || noop) 56 | } 57 | 58 | AirPlay.prototype.scrub = function scrub (position, cb) { 59 | if (typeof position === 'function') return this.scrub(null, position) 60 | 61 | var method, path 62 | if (position === null) { 63 | method = 'GET' 64 | path = '/scrub' 65 | } else { 66 | method = 'POST' 67 | path = '/scrub?position=' + position 68 | } 69 | 70 | this._request(method, path, cb || noop) 71 | } 72 | 73 | AirPlay.prototype.rate = function rate (speed, cb) { 74 | this._post('/rate?value=' + speed, cb || noop) 75 | } 76 | 77 | AirPlay.prototype.pause = function pause (cb) { 78 | this.rate(0, cb) 79 | } 80 | 81 | AirPlay.prototype.resume = function pause (cb) { 82 | this.rate(1, cb) 83 | } 84 | 85 | AirPlay.prototype.stop = function stop (cb) { 86 | this._post('/stop', cb || noop) 87 | } 88 | 89 | AirPlay.prototype.playbackInfo = function playbackInfo (cb) { 90 | this._get('/playback-info', cb) 91 | } 92 | 93 | AirPlay.prototype.property = function property (name, value, cb) { 94 | if (typeof value === 'function') return this.property(name, null, value) 95 | 96 | var method, path 97 | if (value === null) { 98 | method = 'POST' 99 | path = '/getProperty?' + name 100 | } else { 101 | method = 'PUT' 102 | path = '/setProperty?' + name 103 | } 104 | 105 | this._request(method, path, value, cb) 106 | } 107 | 108 | AirPlay.prototype._get = function _get (path, body, cb) { 109 | this._request('GET', path, body, cb) 110 | } 111 | 112 | AirPlay.prototype._post = function _post (path, body, cb) { 113 | this._request('POST', path, body, cb) 114 | } 115 | 116 | AirPlay.prototype._request = function _request (method, path, body, cb) { 117 | if (typeof body === 'function') return this._request(method, path, null, body) 118 | 119 | var opts = { 120 | host: this.host, 121 | port: this.port, 122 | method: method, 123 | path: path, 124 | headers: { 125 | 'User-Agent': USER_AGENT 126 | }, 127 | agent: this._agent // The Apple TV will refuse to play if the play socket is closed 128 | } 129 | 130 | if (body && typeof body === 'object') { 131 | body = bplistEncode(body) 132 | opts.headers['Content-Type'] = 'application/x-apple-binary-plist' 133 | opts.headers['Content-Length'] = body.length 134 | } else if (typeof body === 'string') { 135 | opts.headers['Content-Type'] = 'text/parameters' 136 | opts.headers['Content-Length'] = Buffer.byteLength(body) 137 | } else { 138 | opts.headers['Content-Length'] = 0 139 | } 140 | 141 | var req = http.request(opts, function (res) { 142 | if (res.statusCode !== 200) var err = new Error('Unexpected response from Apple TV: ' + res.statusCode) 143 | 144 | var buffers = [] 145 | res.on('data', buffers.push.bind(buffers)) 146 | res.on('end', function () { 147 | var body = Buffer.concat(buffers) 148 | 149 | switch (res.headers['content-type']) { 150 | case 'application/x-apple-binary-plist': 151 | body = bplistDecode(body)[0] 152 | break 153 | case 'text/x-apple-plist+xml': 154 | body = plist.parse(body.toString()) 155 | break 156 | case 'text/parameters': 157 | body = body.toString().trim().split('\n').reduce(function (body, line) { 158 | line = line.split(': ') 159 | // TODO: For now it's only floats, but it might be better to not expect that 160 | body[line[0]] = parseFloat(line[1], 10) 161 | return body 162 | }, {}) 163 | break 164 | } 165 | 166 | cb(err, res, body) 167 | }) 168 | }) 169 | 170 | req.end(body) 171 | } 172 | 173 | AirPlay.prototype._startReverse = function _startReverse () { 174 | if (this._rserver) this._rserver.destroy() 175 | 176 | var self = this 177 | var opts = { 178 | host: this.host, 179 | port: this.port, 180 | path: '/reverse', 181 | headers: { 182 | 'User-Agent': USER_AGENT 183 | } 184 | } 185 | 186 | this._rserver = reverseHttp(opts, function (req, res) { 187 | if (req.method !== 'POST' || req.url !== '/event') { 188 | // TODO: Maybe we should just accept it silently? 189 | res.statusCode = 404 190 | res.end() 191 | return 192 | } 193 | 194 | req.pipe(concat(function (data) { 195 | res.end() 196 | 197 | switch (req.headers['content-type']) { 198 | case 'text/x-apple-plist+xml': 199 | case 'application/x-apple-plist': 200 | data = plist.parse(data.toString()) 201 | if (data && data.state) { 202 | self.state = data.state 203 | if (self.state === 'stopped') self.destroy() 204 | } 205 | break 206 | } 207 | 208 | self.emit('event', data) 209 | })) 210 | }) 211 | } 212 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airplay-protocol", 3 | "version": "2.0.2", 4 | "description": "A low level protocol wrapper on top of the AirPlay HTTP API", 5 | "main": "index.js", 6 | "dependencies": { 7 | "bplist-creator": "^0.0.7", 8 | "bplist-parser": "^0.1.1", 9 | "concat-stream": "^1.5.2", 10 | "plist": "^2.0.1", 11 | "reverse-http": "^1.2.0" 12 | }, 13 | "devDependencies": { 14 | "standard": "^8.5.0", 15 | "tape": "^4.6.2" 16 | }, 17 | "scripts": { 18 | "test": "standard && tape test.js" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/watson/airplay-protocol.git" 23 | }, 24 | "keywords": [ 25 | "airplay", 26 | "protcol", 27 | "http", 28 | "api", 29 | "video", 30 | "stream", 31 | "streaming" 32 | ], 33 | "engines": { 34 | "node": ">= 0.12" 35 | }, 36 | "author": "Thomas Watson Steen (https://twitter.com/wa7son)", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/watson/airplay-protocol/issues" 40 | }, 41 | "homepage": "https://github.com/watson/airplay-protocol#readme", 42 | "coordinates": [ 43 | 52.5306897, 44 | 13.3840121 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var test = require('tape') 4 | var http = require('http') 5 | var bplist = require('bplist-parser') 6 | var AirPlay = require('./') 7 | 8 | test('setup reverse HTTP', function (t) { 9 | t.plan(1) 10 | 11 | var server = http.createServer(function (req, res) { 12 | t.fail('unexpected HTTP request') 13 | }) 14 | 15 | server.on('upgrade', onUpgrade) 16 | 17 | server.listen(function () { 18 | var airplay = new AirPlay('localhost', server.address().port) 19 | 20 | airplay._startReverse() 21 | 22 | airplay.on('event', function (event) { 23 | server.close() 24 | airplay.destroy() 25 | t.deepEqual(event, { category: 'video', sessionID: 13, state: 'paused' }) 26 | }) 27 | }) 28 | }) 29 | 30 | test('airplay.state', function (t) { 31 | var server = http.createServer() 32 | 33 | server.on('upgrade', onUpgrade) 34 | 35 | server.listen(function () { 36 | var airplay = new AirPlay('localhost', server.address().port) 37 | 38 | airplay._startReverse() 39 | 40 | t.equal(airplay.state, null) 41 | 42 | airplay.on('event', function (event) { 43 | server.close() 44 | airplay.destroy() 45 | t.equal(airplay.state, event.state) 46 | t.end() 47 | }) 48 | }) 49 | }) 50 | 51 | test('serverInfo', function (t) { 52 | var server = http.createServer(function (req, res) { 53 | t.equal(req.method, 'GET') 54 | t.equal(req.url, '/server-info') 55 | res.end() 56 | }) 57 | 58 | server.on('upgrade', onUpgrade) 59 | 60 | server.listen(function () { 61 | var airplay = new AirPlay('localhost', server.address().port) 62 | 63 | airplay.serverInfo(function (err, res, body) { 64 | server.close() 65 | airplay.destroy() 66 | t.error(err) 67 | t.equal(res.statusCode, 200) 68 | t.deepEqual(body, Buffer(0)) 69 | t.end() 70 | }) 71 | }) 72 | }) 73 | 74 | test('play', function (t) { 75 | var server = http.createServer(function (req, res) { 76 | t.equal(req.method, 'POST') 77 | t.equal(req.url, '/play') 78 | req.on('data', function (chunk) { 79 | t.equal(chunk.toString(), 'Content-Location: foo\nStart-Position: 0\n') 80 | res.end() 81 | }) 82 | }) 83 | 84 | server.on('upgrade', onUpgrade) 85 | 86 | server.listen(function () { 87 | var airplay = new AirPlay('localhost', server.address().port) 88 | 89 | airplay.play('foo', function (err, res, body) { 90 | server.close() 91 | airplay.destroy() 92 | t.error(err) 93 | t.equal(res.statusCode, 200) 94 | t.deepEqual(body, Buffer(0)) 95 | t.end() 96 | }) 97 | }) 98 | }) 99 | 100 | test('play', function (t) { 101 | var server = http.createServer(function (req, res) { 102 | t.equal(req.method, 'POST') 103 | t.equal(req.url, '/play') 104 | req.on('data', function (chunk) { 105 | t.equal(chunk.toString(), 'Content-Location: foo\nStart-Position: 0.42\n') 106 | res.end() 107 | }) 108 | }) 109 | 110 | server.on('upgrade', onUpgrade) 111 | 112 | server.listen(function () { 113 | var airplay = new AirPlay('localhost', server.address().port) 114 | 115 | airplay.play('foo', 0.42, function (err, res, body) { 116 | server.close() 117 | airplay.destroy() 118 | t.error(err) 119 | t.equal(res.statusCode, 200) 120 | t.deepEqual(body, Buffer(0)) 121 | t.end() 122 | }) 123 | }) 124 | }) 125 | 126 | test('get scrub', function (t) { 127 | var server = http.createServer(function (req, res) { 128 | t.equal(req.method, 'GET') 129 | t.equal(req.url, '/scrub') 130 | res.end() 131 | }) 132 | 133 | server.on('upgrade', onUpgrade) 134 | 135 | server.listen(function () { 136 | var airplay = new AirPlay('localhost', server.address().port) 137 | 138 | airplay.scrub(function (err, res, body) { 139 | server.close() 140 | airplay.destroy() 141 | t.error(err) 142 | t.equal(res.statusCode, 200) 143 | t.deepEqual(body, Buffer(0)) 144 | t.end() 145 | }) 146 | }) 147 | }) 148 | 149 | test('set scrub', function (t) { 150 | var server = http.createServer(function (req, res) { 151 | t.equal(req.method, 'POST') 152 | t.equal(req.url, '/scrub?position=42') 153 | res.end() 154 | }) 155 | 156 | server.on('upgrade', onUpgrade) 157 | 158 | server.listen(function () { 159 | var airplay = new AirPlay('localhost', server.address().port) 160 | 161 | airplay.scrub(42, function (err, res, body) { 162 | server.close() 163 | airplay.destroy() 164 | t.error(err) 165 | t.equal(res.statusCode, 200) 166 | t.deepEqual(body, Buffer(0)) 167 | t.end() 168 | }) 169 | }) 170 | }) 171 | 172 | test('rate', function (t) { 173 | var server = http.createServer(function (req, res) { 174 | t.equal(req.method, 'POST') 175 | t.equal(req.url, '/rate?value=0.42') 176 | res.end() 177 | }) 178 | 179 | server.on('upgrade', onUpgrade) 180 | 181 | server.listen(function () { 182 | var airplay = new AirPlay('localhost', server.address().port) 183 | 184 | airplay.rate(0.42, function (err, res, body) { 185 | server.close() 186 | airplay.destroy() 187 | t.error(err) 188 | t.equal(res.statusCode, 200) 189 | t.deepEqual(body, Buffer(0)) 190 | t.end() 191 | }) 192 | }) 193 | }) 194 | 195 | test('pause', function (t) { 196 | var server = http.createServer(function (req, res) { 197 | t.equal(req.method, 'POST') 198 | t.equal(req.url, '/rate?value=0') 199 | res.end() 200 | }) 201 | 202 | server.on('upgrade', onUpgrade) 203 | 204 | server.listen(function () { 205 | var airplay = new AirPlay('localhost', server.address().port) 206 | 207 | airplay.pause(function (err, res, body) { 208 | server.close() 209 | airplay.destroy() 210 | t.error(err) 211 | t.equal(res.statusCode, 200) 212 | t.deepEqual(body, Buffer(0)) 213 | t.end() 214 | }) 215 | }) 216 | }) 217 | 218 | test('resume', function (t) { 219 | var server = http.createServer(function (req, res) { 220 | t.equal(req.method, 'POST') 221 | t.equal(req.url, '/rate?value=1') 222 | res.end() 223 | }) 224 | 225 | server.on('upgrade', onUpgrade) 226 | 227 | server.listen(function () { 228 | var airplay = new AirPlay('localhost', server.address().port) 229 | 230 | airplay.resume(function (err, res, body) { 231 | server.close() 232 | airplay.destroy() 233 | t.error(err) 234 | t.equal(res.statusCode, 200) 235 | t.deepEqual(body, Buffer(0)) 236 | t.end() 237 | }) 238 | }) 239 | }) 240 | 241 | test('stop', function (t) { 242 | var server = http.createServer(function (req, res) { 243 | t.equal(req.method, 'POST') 244 | t.equal(req.url, '/stop') 245 | res.end() 246 | }) 247 | 248 | server.on('upgrade', onUpgrade) 249 | 250 | server.listen(function () { 251 | var airplay = new AirPlay('localhost', server.address().port) 252 | 253 | airplay.stop(function (err, res, body) { 254 | server.close() 255 | airplay.destroy() 256 | t.error(err) 257 | t.equal(res.statusCode, 200) 258 | t.deepEqual(body, Buffer(0)) 259 | t.end() 260 | }) 261 | }) 262 | }) 263 | 264 | test('playbackInfo', function (t) { 265 | var server = http.createServer(function (req, res) { 266 | t.equal(req.method, 'GET') 267 | t.equal(req.url, '/playback-info') 268 | res.end() 269 | }) 270 | 271 | server.on('upgrade', onUpgrade) 272 | 273 | server.listen(function () { 274 | var airplay = new AirPlay('localhost', server.address().port) 275 | 276 | airplay.playbackInfo(function (err, res, body) { 277 | server.close() 278 | airplay.destroy() 279 | t.error(err) 280 | t.equal(res.statusCode, 200) 281 | t.deepEqual(body, Buffer(0)) 282 | t.end() 283 | }) 284 | }) 285 | }) 286 | 287 | test('get property', function (t) { 288 | var server = http.createServer(function (req, res) { 289 | t.equal(req.method, 'POST') 290 | t.equal(req.url, '/getProperty?foo') 291 | res.end() 292 | }) 293 | 294 | server.on('upgrade', onUpgrade) 295 | 296 | server.listen(function () { 297 | var airplay = new AirPlay('localhost', server.address().port) 298 | 299 | airplay.property('foo', function (err, res, body) { 300 | server.close() 301 | airplay.destroy() 302 | t.error(err) 303 | t.equal(res.statusCode, 200) 304 | t.deepEqual(body, Buffer(0)) 305 | t.end() 306 | }) 307 | }) 308 | }) 309 | 310 | test('set property', function (t) { 311 | var server = http.createServer(function (req, res) { 312 | t.equal(req.method, 'PUT') 313 | t.equal(req.url, '/setProperty?foo') 314 | req.on('data', function (chunk) { 315 | t.deepEqual(bplist.parseBuffer(chunk)[0], { foo: 'bar' }) 316 | res.end() 317 | }) 318 | }) 319 | 320 | server.on('upgrade', onUpgrade) 321 | 322 | server.listen(function () { 323 | var airplay = new AirPlay('localhost', server.address().port) 324 | 325 | airplay.property('foo', { foo: 'bar' }, function (err, res, body) { 326 | server.close() 327 | airplay.destroy() 328 | t.error(err) 329 | t.equal(res.statusCode, 200) 330 | t.deepEqual(body, Buffer(0)) 331 | t.end() 332 | }) 333 | }) 334 | }) 335 | 336 | function onUpgrade (req, socket, head) { 337 | socket.write('HTTP/1.1 101 Switching Protocols\r\n' + 338 | 'Upgrade: PTTH/1.0\r\n' + 339 | 'Connection: Upgrade\r\n' + 340 | '\r\n') 341 | socket.write('POST /event HTTP/1.1\r\n' + 342 | 'Content-Type: application/x-apple-plist\r\n' + 343 | 'Content-Length: 342\r\n' + 344 | 'X-Apple-Session-ID: 00000000-0000-0000-0000-000000000000\r\n' + 345 | '\r\n' + 346 | '\r\n' + 347 | '\r\n' + 349 | '\r\n' + 350 | ' \r\n' + 351 | ' category\r\n' + 352 | ' video\r\n' + 353 | ' sessionID\r\n' + 354 | ' 13\r\n' + 355 | ' state\r\n' + 356 | ' paused\r\n' + 357 | ' \r\n' + 358 | '') 359 | } 360 | --------------------------------------------------------------------------------