-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
3809435
commit a2a6644
Showing
6 changed files
with
526 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; |
Oops, something went wrong.