Skip to content

Commit

Permalink
Add initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
ThiefMaster committed Jan 1, 2013
1 parent 3809435 commit a2a6644
Show file tree
Hide file tree
Showing 6 changed files with 526 additions and 0 deletions.
15 changes: 15 additions & 0 deletions jscast.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
passwords:
# password used by a dj
dj: '12345'
# password used for the admin webinterface
admin: 'test12345'
# disallow admin password for dj connections
strictAdmin: false

network:
ip: '0.0.0.0'
# port for listeners. port+1 must be accessible, too
port: 18000

stream:
metaInterval: 8192
127 changes: 127 additions & 0 deletions lib/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
var util = require('util'),
events = require('events'),
uuid = require('node-uuid'),
_ = require('underscore');


function PlaybackClient(manager, req, res, id) {
var self = this;
this.manager = manager;
this.req = req;
this.res = res;
this.id = id;
this.connectTime = Math.floor(Date.now() / 1000);
this.bytesSent = 0;
this.wantsMetadata = (req.headers['icy-metadata'] == '1');
this.lastMetadata = undefined;

req.on('close', function() {
self.emit('close');
});
req.socket.setTimeout(0);

var headers = {
'icy-name': manager.metadata.stationName,
'icy-url': manager.metadata.stationUrl,
'icy-genre': manager.metadata.stationGenre,
'content-type': 'audio/mpeg'
};
if(this.wantsMetadata) {
headers['icy-metaint'] = this.manager.metaInterval;
}
res.useChunkedEncodingByDefault = false;
res.sendDate = false;
res._storeHeader('ICY 200 OK\r\n', headers);
};

util.inherits(PlaybackClient, events.EventEmitter);

_.extend(PlaybackClient.prototype, {
close: function close() {
this.res.end();
this.emit('close')
},

write: function write(data) {
var beforeMeta = this.manager.metaInterval - (this.bytesSent % this.manager.metaInterval);

if(!this.wantsMetadata || data.length < beforeMeta) {
this.res.write(data);
this.bytesSent += data.length;
return;
}

this.res.write(data.slice(0, beforeMeta));
this.bytesSent += beforeMeta;
this._sendMetadata();
if(data.length > beforeMeta) {
// By doing this recursively we avoid problems in the unlikely case that we'd still have more
// than metaInterval bytes left.
this.write(data.slice(beforeMeta));
}
},

_sendMetadata: function _sendMetadata() {
// Nothing is escaped. Not even single quotes. FUGLY! But that's how Shoutcast does it, too.
var metadata = "StreamTitle='" + this.manager.metadata.song + "';";
if(metadata == this.lastMetadata) {
this.res.write(new Buffer([0]));
return;
}
var size = Math.ceil(metadata.length / 16);
var buf = new Buffer(1 + size * 16);
buf.fill(0);
buf.writeUInt8(size, 0);
buf.write(metadata, 1);
this.res.write(buf);
this.lastMetadata = metadata;
}
});


function ClientManager(metaInterval, metadata) {
this.clients = {};
this.metaInterval = metaInterval;
this.metadata = metadata;
}

util.inherits(ClientManager, events.EventEmitter);

_.extend(ClientManager.prototype, {
newClient: function newClient(req, res) {
var self = this;
var id;
do {
id = uuid.v4();
} while(id in this.clients);

var client = this.clients[id] = new PlaybackClient(this, req, res, id);
client.on('close', function() {
delete self.clients[client.id]
self.emit('clientDisconnected', client);
});
this.emit('clientConnected', client);
return client;
},

getClient: function getClient(id) {
return this.clients[id];
},

kickClient: function kickClient(id) {
var client = this.clients[id];
if(client) {
client.close();
}
},

broadcast: function broadcast(data) {
_.each(this.clients, function(client) {
client.write(data);
});
}
});

module.exports = {
ClientManager: ClientManager
};
201 changes: 201 additions & 0 deletions lib/jscast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
var util = require('util'),
events = require('events'),
express = require('express'),
goodwin = require('goodwin'),
_ = require('underscore'),
ClientManager = require('./client').ClientManager,
createSourceServer = require('./source').createSourceServer;

// Maps request header sent by the source to JSCast.metadata fields
var metaMap = {
'icy-name': 'stationName',
'icy-url': 'stationUrl',
'icy-genre': 'stationGenre'
};


function JSCast(settings) {
this.settings = settings;
this._validateSettings();

// Stream-wide metadata
this.metadata = {
song: 'N/A',
stationName: 'N/A',
stationUrl: 'http://www.example.com',
stationGenre: 'Various'
};

this.activeSource = null;
this._initClientManager();
this._initSourceServer();
this._initHttpServer();
this._bindHttpRoutes();
}

util.inherits(JSCast, events.EventEmitter);

_.extend(JSCast.prototype, {
listen: function listen() {
this.httpServer.listen(this.settings.network.port, this.settings.network.ip);
this.sourceServer.listen(this.settings.network.port + 1, this.settings.network.ip);
},

_validateSettings: function _validateSettings() {
var self = this;
var requiredSettings = [
'passwords.dj:string',
'passwords.admin:string',
'network.ip:string',
'network.port:number',
'stream.metaInterval:number'
];
var failed = false;
_.each(requiredSettings, function(item) {
var key, type;
if(~item.indexOf(':')) {
var parts = item.split(':');
key = parts[0];
type = parts[1];
}
else {
key = item;
}
var value = goodwin.getPathValue(key, self.settings);
if(value === undefined) {
console.error('Required config option ' + key + ' is missing');
failed = true;
}
else if(type !== undefined && typeof value != type) {
console.error('Required config option ' + key + ' must be a ' + type);
failed = true;
}
});

if(failed) {
throw new Exception('Configuration check failed.');
}
},

_initClientManager: function _initClientManager() {
this.clientManager = new ClientManager(this.settings.stream.metaInterval, this.metadata);
this.clientManager.on('clientConnected', function(client) {
console.log('Client connected: ' + client.id);
});
this.clientManager.on('clientDisconnected', function(client) {
console.log('Client disconnected: ' + client.id);
});
},

_initSourceServer: function _initSourceServer() {
var self = this;
this.sourceServer = createSourceServer(this.settings.authenticator);
this.sourceServer.on('sourceConnected', function(source) {
console.log('Source connected: ' + source.clientAddress);
source.on('close', function() {
console.log('Source closed: ' + this.clientAddress);
if(this === self.activeSource) {
console.log('Active source client lost!');
self.activeSource = null;
}
});
source.on('streaming', function() {
console.log('Source started streaming: ' + this.clientAddress);
self.activeSource = this;
});
source.on('metadata', function(key, value) {
console.log('Source sent metadata: ' + key + ' = ' + value);
if(key in metaMap) {
self.metadata[metaMap[key]] = value;
}
});
source.on('audio', function(data) {
self.clientManager.broadcast(data);
});
});
},

_initHttpServer: function _initHttpServer() {
var app = this.httpServer = express();
app.configure(function() {
app.use(express.logger('dev'));
app.use(express.bodyParser());
app.use(app.router);
});

app.configure('development', function(){
app.use(express.errorHandler());
});
},

_bindHttpRoutes: function _bindHttpRoutes() {
var self = this,
app = this.httpServer; // for convenience

// index page. starts the stream if UA is apparently not a browser
app.get('/', function(req, res) {
if(!~req.headers['user-agent'].indexOf('Mozilla')) {
self.clientManager.newClient(req, res);
return;
}

res.end('Welcome to NodeCast!')
});

// optional entry point that always starts the stream
app.get('/;', function(req, res) {
self.clientManager.newClient(req, res);
});

// admin interface. must be called like this so shoutcast sources do not break
// TODO: move away everything that's not API-ish
app.get('/admin.cgi', function(req, res) {
var command = req.query.mode;
var password = req.query.pass;
var requireAdmin = (command != 'updinfo');

self.settings.authenticator(req.ip, password, requireAdmin, function(valid) {
if(!valid) {
res.send(403, 'Authentication failed.');
res.end();
return;
}

self._handleAdminCommand(req, res, command);
});
});
},

_handleAdminCommand: function _handleAdminCommand(req, res, command) {
if(!command) {
res.write('Clients:\n');
_.each(clientManager.clients, function(client) {
res.write('* ' + client.id + ' [' + client.req.ip + '] - ' + client.bytesSent + ' bytes\n');
});
res.write('\n\Source: ' + (activeSource ? activeSource.ip : 'None'));
}
else if(command == 'updinfo') {
this.metadata.song = req.query.song;
console.log('Song title: ' + this.metadata.song);
}
else if(command == 'kicksrc') {
if(activeSource) {
activeSource.close();
}
console.log('Kicked source');
}
else if(command == 'kickdst') {
clientManager.kickClient(req.query.dst);
console.log('Kicked listener');
}
else {
res.send(404, 'Not Found.');
}

res.end();
}
});

module.exports = {
JSCast: JSCast
};
Loading

0 comments on commit a2a6644

Please sign in to comment.