radio-stream
Version:
An interface for connecting to, parsing metadata, and reading from SHOUTcast/Icecast radio streams
314 lines (263 loc) • 10.5 kB
JavaScript
const R = '\r'.charCodeAt(0);
const N = '\n'.charCodeAt(0);
const META_BLOCK_SIZE = 16;
var fs = require("fs");
var net = require("net");
var parse = require("url").parse;
var EventEmitter = require("events").EventEmitter;
/**
* Create's an Internet Radio `ReadStream`. It emits "data" events similar to
* the `fs` module's `ReadStream`, but never emits an "end" event (it's an infinite
* radio stream). The Internet Radio `ReadStream` also emits a "metadata" event
* which occurs after a metadata chunk has been recieved and parsed, for your
* Node application to do something useful with.
*/
function ReadStream(url, retainMetadata) {
this.url = new String(url);
var parsedUrl = parse(url);
parsedUrl.__proto__ = this.url.__proto__;
this.url.__proto__ = parsedUrl;
// Not currently used:
// TODO: If this is true, emit the metadata bytes as well
this.retainMetadata = retainMetadata;
this.connection = net.createConnection(this.url.port || (this.url.protocol == "https:" ? 443 : 80), this.url.hostname);
this.connection.on("connect", this.onConnect.bind(this));
this.connection.on("close", this.onClose.bind(this));
this.connection.on("error", this.onError.bind(this));
this.bindedOnMetaData = this.onMetaData.bind(this);
this.bindedOnMetaLengthByte = this.onMetaLengthByte.bind(this);
this.connection.on("data", (this.bindedOnData = this.onDataHeader.bind(this)));
// The counter used to keep track of count of the audio/metadata bytes parsed.
this.counter = 0;
}
exports.ReadStream = ReadStream;
// Make `ReadStream` inherit from `EventEmitter`
ReadStream.prototype = Object.create(EventEmitter.prototype, {
constructor: {
value: ReadStream,
enumerable: false
}
});
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;
}
// False immediately after instantiation, set to `true` right before the
// 'connect' event is fired.
ReadStream.prototype.connected = false;
// A boolean that is true by default, but turns false after an 'error' occured,
// or destroy() was called.
ReadStream.prototype.readable = true;
// Once connected, send a minimal HTTP request to connect to the radio stream.
ReadStream.prototype.onConnect = function() {
this.connection.write(this.generateRequest());
}
// Called when the underlying "net" Stream emits a "data" event.
// Emits 'data' events passing 'chunk' until "metaint" bytes have
// been sent, then it sets 'onMetaLengthByte' for 'data' events.
ReadStream.prototype.onData = function(chunk) {
if (this.metaint && this.counter == this.metaint) {
this.counter = 0;
this.connection.removeListener("data", this.bindedOnData);
this.connection.addListener("data", this.bindedOnMetaLengthByte);
this.connection.emit("data", 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, chunk.length);
this.connection.emit("data", metadata);
}
} else if (chunk.length) {
this.emit("data", chunk);
this.counter += chunk.length;
}
}
// Called when the underlying "net" Stream emits a "data" event.
// This is the initial HTTP response header parsing logic.
ReadStream.prototype.onDataHeader = function(chunk) {
// Append 'chunk' into the 'response' variable
if (this.headerBuffer) {
this.headerBuffer = exports.appendBuffer(this.headerBuffer, chunk);
} else {
this.headerBuffer = chunk;
}
// If there's less than 8 bytes, it's still an unplausable response header,
// don't even bother checking this time around, check the next 'data' event.
if (this.headerBuffer.length < 8) return;
// Check to see if the end of the header has been reached.
// If so we need to determine what kind of response we got.
for (var i=0, l=Math.min(this.headerBuffer.length-3, 8192); i<l; i++) {
// Check for /r/n/r/n
if (this.headerBuffer[i] == R && this.headerBuffer[i+1] == N && this.headerBuffer[i+2] == R && this.headerBuffer[i+3] == N) {
// We found the end of the header!
var leftover = this.headerBuffer.slice(i+4, this.headerBuffer.length);
this.headerBuffer = this.headerBuffer.slice(0, i);
this.parseHeaders();
this.connection.removeListener("data", this.bindedOnData);
this.connection.addListener("data", (this.bindedOnData = this.onData.bind(this)));
// Emit the "connect" event. The headers are parsed now, so the user can
// inspect them, and set up `ffmpeg` based on the 'content-type' perhaps?
this.emit("connect", this);
// If there are any bytes leftover after the header, then it's audio data that should
// be handled by the new 'onData' callback, then passed back to the user.
if (leftover.length > 0) {
this.connection.emit("data", leftover);
}
return;
}
}
}
// Called when the underlying "net" Stream emits a "data" event.
// This 'data' parser is used when the radio stream is sending a 'metadata' event.
ReadStream.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.toString());
//console.error("Meta Bytes Recieved: " + this.counter + ", " + this.metaBuffer.length);
this.metaBuffer = null;
this.metaLength = null;
this.counter = 0;
this.connection.removeListener("data", this.bindedOnMetaData);
this.connection.addListener("data", this.bindedOnData);
if (metaEnd+1 < chunk.length) {
var remainder = chunk.slice(metaEnd+1, chunk.length);
//console.error(remainder.slice(0, Math.min(5, remainder.length)));
this.connection.emit("data", 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.
ReadStream.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.connection.removeListener("data", this.bindedOnMetaLengthByte);
this.connection.addListener("data", this.metaLength ? this.bindedOnMetaData : this.bindedOnData);
if (chunk.length > 1) {
var remains = chunk.slice(1, chunk.length);
this.connection.emit("data", remains);
}
}
// Generates the String HTTP request that gets sent to the remote server.
ReadStream.prototype.generateRequest = function() {
return "GET " + (this.url.pathname ? this.url.pathname : "/") + (typeof this.url.search === 'string' ? this.url.search : "") + " HTTP/1.1\r\n"+
"Host: " + this.url.host + "\r\n"+
"Icy-MetaData:1\r\n"+
"\r\n";
}
ReadStream.prototype.parseHeaders = function() {
this.headerString = this.headerBuffer.toString();
// If it's an ICY stream / raw HTTP stream (check content-type), make return the URL itself
var firstLine = this.headerString.substring(0, this.headerString.indexOf("\r")).split(" ");
// It's an ICY stream
if (firstLine[0] == "ICY") {
//console.error("Detected an ICY stream!");
}
this.headers = {};
var headers = this.headerString.split("\r\n").slice(1);
for (var i=0, l=headers.length; i<l; i++) {
var header = headers[i].split(":");
this.headers[header[0].trim()] = header[1].trim();
}
// Permenantly store the "metaint". It's used constantly throughout
// the data parsing logic.
Object.defineProperty(this, "metaint", {
value: this.headers['icy-metaint'],
enumerable: false,
writable: false
});
// A flag to easily determine whether the 'connect' event has been fired or not.
this.connected = true;
}
/**
* Called when the underlying `net.Stream` connection emits a 'close' event.
*/
ReadStream.prototype.onClose = function() {
this.emit("close");
}
/**
* Called when the underlying `net.Stream` connection emits an 'error' event.
*/
ReadStream.prototype.onError = function(err) {
this.connected = false;
this.readable = false;
this.emit("error", err);
}
/**
* Pauses the incoming 'data' events.
*/
ReadStream.prototype.pause = function() {
this.connection.pause();
}
/**
* Resumes the incoming 'data' events after a pause().
*/
ReadStream.prototype.resume = function() {
this.connection.resume();
}
/**
* Closes the underlying `net.Stream`. Stream will not emit any more events.
*/
ReadStream.prototype.destroy = function() {
this.connection.destroy();
this.connected = false;
this.readable = false;
}
/**
* 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, retainMetadata) {
return new ReadStream(url, retainMetadata);
}
exports.createReadStream = createReadStream;
/**
* Accepts the String passed from the 'metadata' event, and parses it into
* a JavaScript object.
*/
function parseMetadata(metadata) {
var rtn = {}, pieces = metadata.split(";"), i=0, l=pieces.length;
for (; i<l; i++) {
var piece = stripNulls(pieces[i]);
if (piece.length) {
piece = piece.split("='");
rtn[piece[0]] = piece[1].substring(0, piece[1].length-1);
}
}
return rtn;
}
exports.parseMetadata = parseMetadata;
function stripNulls(str) {
while(str.indexOf('\0') != -1) {
str = str.replace('\0', '');
}
return str;
}