icecast-stack
Version:
A `StreamStack` implementation for parsing and/or injecting metadata with SHOUTcast/Icecast radio streams.
281 lines (237 loc) • 9.21 kB
JavaScript
var inherits = require('util').inherits;
var StreamStack = require('stream-stack').StreamStack;
// On the meta-length byte, you must multiply/divide by META_BLOCK_SIZE
// to get the real metadata byte-length. We export is as a 'getter' only
// to ensure that it remains Read-Only.
const META_BLOCK_SIZE = 16;
exports.__defineGetter__('META_BLOCK_SIZE', function() { return META_BLOCK_SIZE; });
// Usually, there will be no metadata event to inject (on the WriteStack), so
// a single byte 0 is sent indicating 0 bytes of metadata.
const NO_METADATA_BYTE = new Buffer(1);
NO_METADATA_BYTE[0] = 0;
/**
* A StreamStack meant for ReadStreams, or more specifically, a `net.Stream`
* connected to and receiving audio data from an Icecast radio stream.
* This stack removes the 'metadata' bytes and parses them into a "metadata"
* event that your Node script can do something useful with.
*/
function IcecastReadStack(stream, metaint, retainMetadata) {
StreamStack.call(this, stream, {
data: function(chunk) {
this._currentOnData(chunk);
}
});
// The amount of audio bytes to read before a metadata byte gets sent.
this.metaint = metaint;
// Not currently used:
// TODO: If this is true, emit the metadata bytes as well
this.retainMetadata = retainMetadata || false;
// The counter used to keep track of count of the audio/metadata bytes parsed.
this.counter = 0;
this._currentOnData = this.onData;
}
exports.IcecastReadStack = IcecastReadStack;
inherits(IcecastReadStack, StreamStack);
exports.appendBuffer = function(a, b) {
var temp = new Buffer(a.length + b.length);
a.copy(temp, 0, 0);
b.copy(temp, a.length, 0);
return temp;
}
// Called when the underlying Stream emits a "data" event.
// Emits 'data' events passing 'chunk' until "metaint" bytes have
// been sent, then it sets 'onMetaLengthByte' for 'data' events.
IcecastReadStack.prototype.onData = function(chunk) {
if (this.metaint && this.counter == this.metaint) {
this.counter = 0;
this._currentOnData = this.onMetaLengthByte;
this._currentOnData(chunk);
} else if (this.metaint && this.counter + chunk.length >= this.metaint) {
var audioEnd = this.metaint - this.counter;
var audioChunk = chunk.slice(0, audioEnd);
this.emit("data", audioChunk);
this.counter += audioChunk.length;
// There's still remaining data! It should be metadata!
if (chunk.length != audioChunk.length) {
var metadata = chunk.slice(audioEnd);
this._currentOnData(metadata);
}
} else if (chunk.length) {
this.emit("data", chunk);
this.counter += chunk.length;
}
}
// Called when the underlying Stream emits a "data" event.
// This 'data' parser is used when the radio stream is sending a 'metadata' event.
IcecastReadStack.prototype.onMetaData = function(chunk) {
if (this.counter + chunk.length >= this.metaLength) {
var metaEnd = this.metaLength - this.counter - 1;
this.counter += metaEnd;
var metaChunk = chunk.slice(0, metaEnd);
if (this.metaBuffer) {
this.metaBuffer = exports.appendBuffer(this.metaBuffer, metaChunk);
} else {
this.metaBuffer = metaChunk;
}
this.emit("metadata", this.metaBuffer);
//console.error("Meta Bytes Recieved: " + this.counter + ", " + this.metaBuffer.length);
this.metaBuffer = null;
this.metaLength = null;
this.counter = 0;
this._currentOnData = this.onData;
if (metaEnd+1 < chunk.length) {
var remainder = chunk.slice(metaEnd+1, chunk.length);
//console.error(remainder.slice(0, Math.min(5, remainder.length)));
this._currentOnData(remainder);
}
} else {
if (this.metaBuffer) {
this.metaBuffer = exports.appendBuffer(this.metaBuffer, chunk);
} else {
this.metaBuffer = chunk;
}
this.counter += chunk.length;
}
}
// Called when the underlying "net" Stream emits a "data" event.
// This 'data' handler checks to see if there's any metadata in
// the upcoming bytes.
// 'chunk' is guaranteed to be at least 1 byte long.
IcecastReadStack.prototype.onMetaLengthByte = function(chunk) {
var metaByte = chunk[0];
//console.error("MetaByte: " + metaByte);
this.metaLength = metaByte * META_BLOCK_SIZE;
//console.error("MetaData Length: " + this.metaLength);
this.counter = 0;
this._currentOnData = this.metaLength > 0 ? this.onMetaData : this.onData;
if (chunk.length > 1) {
this._currentOnData(chunk.slice(1));
}
}
/**
* A StreamStack for WriteStreams (specifically `http.ServerResponse`,
* `http.ClientRequest`, and `net.Stream`). It offers a 'queueMetadata(d)'
* function that will inject the passed metadata into the audio stream
* at the next given (metaint) interval.
*/
function IcecastWriteStack(stream, metaint) {
StreamStack.call(this, stream);
this.metaint = metaint;
this.counter = 0;
this._metadataQueue = [];
}
exports.IcecastWriteStack = IcecastWriteStack;
inherits(IcecastWriteStack, StreamStack);
/**
* The 'queueMetadata' function queues a new 'metadata' event to be injected
* upstream at the next metadata interval (metaint).
*/
var streamTitleRegExp = /StreamTitle='/;
IcecastWriteStack.prototype.queueMetadata = function(metadata) {
//console.error(metadata);
if (typeof(metadata) == 'string') {
if (!streamTitleRegExp.test(metadata)) {
// Ensure a 'StreamTitle' is somewhere in the string.
metadata = "StreamTitle='" + metadata + "';";
}
} else if ('StreamTitle' in metadata) {
// A regular object containing at least a StreamTitle property.
// Append the 'StreamTitle' part first, to ensure it's part of the metadata.
var str = "StreamTitle='" + metadata['StreamTitle'] + "';";
delete metadata['StreamTitle'];
// Then append the rest of the properies on the object (if any).
for (var i in metadata) {
str += i + "='" + metadata[i] + "';";
}
metadata = str;
}
//console.error(metadata);
if (!streamTitleRegExp.test(metadata)) {
throw new Error('"queueMetadata()" expects a String, or an object with a "StreamTitle" property.');
}
// Calculate the metalength byte, with a maximum of 255.
var metaLength = Math.min(255, Math.ceil(metadata.length/META_BLOCK_SIZE));
// Create a new Buffer that will contain the inital metalength byte, then
// the metadata itself, and then will pad the rest of the Buffer with null bytes.
var metaBuf = new Buffer((metaLength * META_BLOCK_SIZE)+1);
metaBuf[0] = metaLength;
var end = metaBuf.write(metadata, 1) + 1;
if (end < metaBuf.length) {
// Pad with null bytes
for (var l=metaBuf.length; end<l; end++) {
metaBuf[end] = 0;
}
}
//console.error(metaBuf);
//console.error(require('util').inspect(metaBuf.toString()));
this._metadataQueue.push(metaBuf);
}
/**
* The overwritten 'write()' function takes care of injecting the next item
* in the metadataQueue if metaint bytes have been passed
*/
IcecastWriteStack.prototype.write = function(chunk, enc) {
if (!Buffer.isBuffer(chunk)) {
chunk = new Buffer(chunk, enc);
}
this._writeAudioChunk(chunk);
}
IcecastWriteStack.prototype._writeAudioChunk = function(chunk) {
if (this.counter + chunk.length > this.metaint) {
var first = chunk.slice(0, this.metaint - this.counter);
this._writeAudioChunk(first);
if (first.length < chunk.length) {
this._writeAudioChunk(chunk.slice(first.length));
}
} else {
this.stream.write(chunk);
this.counter += chunk.length;
//console.error('counter: ' + this.counter);
if (this.counter == this.metaint) {
this._injectNextMetadata();
}
}
}
/**
* Checks if there's any entires in the 'metadataQueue' array, and if there are,
* injects the next item in the queue into the audio write stream.
*/
IcecastWriteStack.prototype._injectNextMetadata = function() {
//console.error('Injecting metadata!');
//console.error('counter (metadata): ' + this.counter);
var metaBuf = NO_METADATA_BYTE;
if (this._metadataQueue.length > 0) {
metaBuf = this._metadataQueue.shift();
}
this.stream.write(metaBuf);
this.counter = 0;
}
/**
* Returns a new ReadStream for the given Internet Radio URL.
* First arg is the URL to the radio stream. Second arg is a
* boolean indicating whether or not to include the metadata
* chunks in the 'data' events. Defaults to 'false' (metadata,
* is stripped, parsed, and formatted into the 'metadata' event).
*/
function createReadStream(url, headers, retainMetadata) {
return require('./client').createClient(url, headers, retainMetadata);
}
exports.createReadStream = createReadStream;
// Used to strip 'null' bytes from the metadata event
var nullExp = new RegExp('\0', 'g');
/**
* Accepts the Buffer passed from the 'metadata' event, and parses it into
* a JavaScript object.
*/
function parseMetadata(metadata) {
var rtn = {}, pieces = metadata.toString().replace(nullExp, '').split(";");
for (var i=0, l=pieces.length; i<l; i++) {
var piece = pieces[i];
if (piece.length) {
piece = piece.split("='");
rtn[piece[0]] = piece[1].substring(0, piece[1].length-1);
}
}
return rtn;
}
exports.parseMetadata = parseMetadata;