Skip to content

Commit

Permalink
node-airplay first version!
Browse files Browse the repository at this point in the history
  • Loading branch information
benvanik committed Nov 6, 2011
1 parent 59a4a78 commit 894849c
Show file tree
Hide file tree
Showing 7 changed files with 426 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules

File renamed without changes.
10 changes: 10 additions & 0 deletions lib/airplay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
exports.Browser = require('./airplay/browser').Browser;
exports.createBrowser = function() {
return new exports.Browser();
};

exports.Device = require('./airplay/device').Device;
exports.connectDevice = function(host, port, pass) {
// TODO: connect
return null;
};
76 changes: 76 additions & 0 deletions lib/airplay/browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
var events = require('events');
var mdns = require('mdns');
var util = require('util');

var Device = require('./device').Device;

var Browser = function() {
var self = this;

this.devices_ = {};
this.nextDeviceId_ = 0;

this.browser_ = mdns.createBrowser(mdns.tcp('airplay'));
this.browser_.on('serviceUp', function(info, flags) {
var device = self.findDeviceByInfo_(info);
if (!device) {
device = new Device(self.nextDeviceId_++, info);
self.devices_[device.id] = device;
self.emit('deviceOnline', device);
}
});
this.browser_.on('serviceDown', function(info, flags) {
var device = self.findDeviceByInfo_(info);
if (device) {
device.close();
delete self.devices_[device.id];
self.emit('deviceOffline', device);
}
});
};
util.inherits(Browser, events.EventEmitter);
exports.Browser = Browser;

Browser.prototype.findDeviceByInfo_ = function(info) {
for (var deviceId in this.devices_) {
var device = this.devices_[deviceId];
var matching = true;
for (var key in info) {
if (device.info[key] != info[key]) {
matching = false;
break;
}
}
if (matching) {
return device;
}
}
return null;
};

Browser.prototype.getDevices = function() {
var devices = [];
for (var deviceId in this.devices_) {
var device = this.devices_[deviceId];
if (device.isReady()) {
devices.push(device);
}
}
return devices;
};

Browser.prototype.getDeviceById = function(deviceId) {
var device = this.devices_[deviceId];
if (device && device.isReady()) {
return device;
}
return null;
};

Browser.prototype.start = function() {
this.browser_.start();
};

Browser.prototype.stop = function() {
this.browser_.stop();
};
134 changes: 134 additions & 0 deletions lib/airplay/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
var buffer = require('buffer');
var events = require('events');
var net = require('net');
var util = require('util');

var Client = function(host, port, user, pass, callback) {
var self = this;

this.host_ = host;
this.port_ = port;
this.user_ = user;
this.pass_ = pass;

this.responseWaiters_ = [];

this.socket_ = net.createConnection(port, host);
this.socket_.on('connect', function() {
self.responseWaiters_.push({
callback: callback
});
self.socket_.write(
'GET /playback-info HTTP/1.1\n' +
'User-Agent: MediaControl/1.0\n' +
'Content-Length: 0\n' +
'\n');
});

this.socket_.on('data', function(data) {
var res = self.parseResponse_(data.toString());
//util.puts(util.inspect(res));

var waiter = self.responseWaiters_.shift();
if (waiter.callback) {
waiter.callback(res);
}
});
};
util.inherits(Client, events.EventEmitter);
exports.Client = Client;

Client.prototype.close = function() {
if (this.socket_) {
this.socket_.close();
}
this.socket_ = null;
};

Client.prototype.parseResponse_ = function(res) {
// Look for HTTP response:
// HTTP/1.1 200 OK
// Some-Header: value
// Content-Length: 427
// \n
// body (427 bytes)

var header = res;
var body = '';
var splitPoint = res.indexOf('\r\n\r\n');
if (splitPoint != -1) {
header = res.substr(0, splitPoint);
body = res.substr(splitPoint + 4);
}

// Normalize header \r\n -> \n
header = header.replace(/\r\n/g, '\n');

// Peel off status
var status = header.substr(0, header.indexOf('\n'));
var statusMatch = status.match(/HTTP\/1.1 ([0-9]+) (.+)/);
header = header.substr(status.length + 1);

// Parse headers
var allHeaders = {};
var headerLines = header.split('\n');
for (var n = 0; n < headerLines.length; n++) {
var headerLine = headerLines[n];
var key = headerLine.substr(0, headerLine.indexOf(':'));
var value = headerLine.substr(key.length + 2);
allHeaders[key] = value;
}

// Trim body?
return {
statusCode: parseInt(statusMatch[1]),
statusReason: statusMatch[2],
headers: allHeaders,
body: body
};
};

Client.prototype.issue_ = function(req, body, callback) {
if (!this.socket_) {
util.puts('client not connected');
return;
}

req.headers = req.headers || {};
req.headers['User-Agent'] = 'MediaControl/1.0';
req.headers['Content-Length'] = body ? buffer.Buffer.byteLength(body) : 0;
req.headers['Connection'] = 'keep-alive';

var allHeaders = '';
for (var key in req.headers) {
allHeaders += key + ': ' + req.headers[key] + '\n';
}

var text =
req.method + ' ' + req.path + ' HTTP/1.1\n' +
allHeaders + '\n';
if (body) {
text += body;
}

this.responseWaiters_.push({
callback: callback
});
this.socket_.write(text);
};

Client.prototype.get = function(path, callback) {
var req = {
method: 'GET',
path: path,
};
this.issue_(req, null, callback);
};

Client.prototype.post = function(path, body, callback) {
var req = {
method: 'POST',
path: path,
};
this.issue_(req, body, callback);
};
173 changes: 173 additions & 0 deletions lib/airplay/device.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
var events = require('events');
var plist = require('plist');
var util = require('util');

var Client = require('./client').Client;

var Device = function(id, info, opt_readyCallback) {
var self = this;

this.id = id;
this.info_ = info;
this.serverInfo_ = null;
this.ready_ = false;

var host = info.host;
var port = info.port;
var user = 'Airplay';
var pass = '';
this.client_ = new Client(host, port, user, pass, function() {
// TODO: support passwords

self.client_.get('/server-info', function(res) {
plist.parseString(res.body, function(err, obj) {
var el = obj[0];
self.serverInfo_ = {
deviceId: el.deviceid,
features: el.features,
model: el.model,
protocolVersion: el.protovers,
sourceVersion: el.srcvers
};
});

self.makeReady_(opt_readyCallback);
});
});
};
util.inherits(Device, events.EventEmitter);
exports.Device = Device;

Device.prototype.isReady = function() {
return this.ready_;
};

Device.prototype.makeReady_ = function(opt_readyCallback) {
this.ready_ = true;
if (opt_readyCallback) {
opt_readyCallback(this);
}
};

Device.prototype.close = function() {
if (this.client_) {
this.client_.close();
}
this.client_ = null;
this.ready_ = false;

this.emit('close');
};

Device.prototype.getInfo = function() {
var info = this.info_;
var serverInfo = this.serverInfo_;
return {
id: this.id,
name: info.serviceName,
deviceId: info.host,
features: serverInfo.features,
model: serverInfo.model,
slideshowFeatures: [],
supportedContentTypes: []
};
};

Device.prototype.default = function(callback) {
if (callback) {
callback(this.getInfo());
}
};

Device.prototype.status = function(callback) {
this.client_.get('/playback-info', function(res) {
if (res) {
plist.parseString(res.body, function(err, obj) {
var el = obj[0];
var result = {
duration: el.duration,
position: el.position,
rate: el.rate,
playbackBufferEmpty: el.playbackBufferEmpty,
playbackBufferFull: el.playbackBufferFull,
playbackLikelyToKeepUp: el.playbackLikelyToKeepUp,
readyToPlay: el.readyToPlay,
loadedTimeRanges: el.loadedTimeRanges,
seekableTimeRanges: el.seekableTimeRanges
};
if (callback) {
callback(result);
}
});
} else {
if (callback) {
callback(null);
}
}
});
};

Device.prototype.authorize = function(req, callback) {
// TODO: implement authorize
if (callback) {
callback(null);
}
};

Device.prototype.play = function(content, start, callback) {
var body =
'Content-Location: ' + content + '\n' +
'Start-Position: ' + start + '\n';
this.client_.post('/play', body, function(res) {
if (callback) {
callback(res ? {} : null);
}
});
};

Device.prototype.stop = function(callback) {
this.client_.post('/stop', null, function(res) {
if (callback) {
callback(res ? {} : null);
}
});
};

Device.prototype.scrub = function(position, callback) {
this.client_.post('/scrub?position=' + position, null, function(res) {
if (callback) {
callback(res ? {} : null);
}
});
};

Device.prototype.reverse = function(callback) {
this.client_.post('/reverse', null, function(res) {
if (callback) {
callback(res ? {} : null);
}
})
};

Device.prototype.rate = function(value, callback) {
this.client_.post('/rate?value=' + value, null, function(res) {
if (callback) {
callback(res ? {} : null);
}
})
};

Device.prototype.volume = function(value, callback) {
this.client_.post('/volume?value=' + value, null, function(res) {
if (callback) {
callback(res ? {} : null);
}
})
};

Device.prototype.photo = function(req, callback) {
// TODO: implement photo
if (callback) {
callback(null);
}
};
Loading

0 comments on commit 894849c

Please sign in to comment.