UNPKG

icecast-stack

Version:

A `StreamStack` implementation for parsing and/or injecting metadata with SHOUTcast/Icecast radio streams.

281 lines (237 loc) 9.21 kB
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;