diff --git a/README.md b/README.md index 25b05d2..ea42191 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,13 @@ [![NPM Version][npm-image]][downloads-url] [![NPM Downloads][downloads-image]][downloads-url] -**A SHOUTcast Server/Library written in JavaScript** +**An Audio Streaming Application written in JavaScript** + +- _storage types / API_ +- _input (item) types / API_ +- _plugin types / API_ +- _CLI support_ + - _whitelist / blacklist_ ![jscast - manage](/docs/images/jscast-manage.png) @@ -16,7 +22,7 @@ Install jscast globally: $ npm i -g jscast ``` -Use the new command to start a Server: +Use the new command to start an instance: ```sh $ jscast @@ -24,6 +30,7 @@ $ jscast - override default port: `-p PORT` / `--port PORT` - change storage type: `-s TYPE` / `--storage-type TYPE` +- set active plugins: `-t TYPE1,TYPE2` / `--plugin-types TYPE1,TYPE2` - ffmpeg binary path: `--ffmpeg-path PATH` - initial youtube items - fillable storage types **only**: `--youtube-items URL1,URL2` - whitelist: `--whitelist COUNTRY1,COUNTRY2` @@ -32,15 +39,59 @@ $ jscast ### Using Script ```javascript -var Server = require("jscast").Server; - -new Server().on("play", function (item, metadata) { - console.log("playing " + metadata.options.StreamTitle); -}).listen(8000, function (server) { - console.log("jscast server is running"); - console.log("listen on http://localhost:" + server.port + server.icyServerRootPath); - console.log("manage on http://localhost:" + server.port + server.manageRootPath + " your playlists and items"); -}); +import jscast from "jscast"; +import { + log +} from "util"; + +const instance = jscast() + .on("clientRejected", (client) => { + log(`client ${client.ip} rejected`); + }); + +const icyServer = instance.pluginManager.getActiveType("IcyServer"); +const manage = instance.pluginManager.getActiveType("Manage"); + +instance + .station + .on("play", (item, metadata) => { + log(`playing ${metadata.options.StreamTitle}`); + }) + .on("nothingToPlay", (playlist) => { + if (!playlist) { + log("no playlist"); + } else { + log("playlist is empty"); + } + }); + +instance + .start({ + port: 8000, + allow: (client) => { + return true; // allow this client + } + }) + .then(() => { + log(`jscast is running`); + + if (icyServer) { + icyServer + .on("clientConnect", (client) => { + log(`icy client ${client.ip} connected`); + }) + .on("clientDisconnect", (client) => { + log(`icy client ${client.ip} disconnected`); + }); + + log(`listen on http://localhost:${icyServer.port}${icyServer.rootPath}`); + } + + if (manage) { + log(`manage on http://localhost:${manage.port}${manage.rootPath} your playlists and items`); + } + }) + .catch((err) => console.error(err)); ``` ## Prerequisites @@ -66,17 +117,19 @@ $ npm i $ npm start ``` -## Manage +## Plugin Types + +### Manage **Manage** is a `webapp` to control jscast playlists and items. the route is `/manage` by default. At the moment there is just a `YouTube` type implemented but the idea is to `control` everything with `manage`. There is also a `player` (using a audio tag) embedded to `play` the `SHOUTcast output`, however for me this worked only with a `Desktop-Browser`. god knows why... -## IcyServer +### IcyServer The **IcyServer**'s task is to send the `SHOUTcast data` (received from the Station) to the `clients`. the route is `/` by default. -## Server +### Speaker -The jscast **Server** combines `Manage` and the `IcyServer` to a simple to use application. +This Plugin outputs the current track to the speakers. ## Station @@ -108,32 +161,38 @@ If thats not enough, you can create [your own one](#custom-storages) jscast has playlists with [typed items](#item-types). You can easily add your own item type: ```javascript -var fs = require("fs"); -var jscast = require("jscast"); -var Item = jscast.Item; -var Server = jscast.Server; +import fs from "fs"; +import { + default as jscast, + Item +} from "jscast"; +import { + log +} from "util"; + +class MyItemType { + constructor() { + this.streamNeedsPostProcessing = true; // indicates if stream should be post processed to mp3 + } -function MyItemType() { - this.streamNeedsPostProcessing = true; // indicates if stream should be post processed to mp3 -} + getStream(item, done) { + // get stream code... + log(item.type); // MyItem + done && done(err, stream); + } -MyItemType.prototype.getStream = function (item, done) { - // get stream code... - console.log(item.type); // MyItem - done && done(err, stream); -}; - -MyItemType.prototype.getMetadata = function (item, done) { - // get metadata code... - console.log(item.options.myProp); // myValue - done && done(err, { - StreamTitle: "my title" - }); -}; + getMetadata(item, done) { + // get metadata code... + log(item.options.myProp); // myValue + done && done(err, { + StreamTitle: "my title" + }); + } +} Item.registerType("MyItem", new MyItemType()); -new Server({ +jscast({ stationOptions: { storageType: "Memory", playlists: [{ @@ -160,7 +219,9 @@ new Server({ } }] } -}).listen(); +}) +.start() +.catch((err) => console.error(err)); ``` ### Custom Storages @@ -168,58 +229,63 @@ new Server({ You can use the built-in [storage types](#storage-types) or create your own one: ```javascript -var fs = require("fs"); -var jscast = require("jscast"); -var Storage = jscast.Storage; -var Server = jscast.Server; - -function MyStorageType() { - this.isFillable = true; // indicates that this type can be pre filled on init -} +import { + default as jscast, + Storage +} from "jscast"; + +class MyStorageType { + constructor() { + this.isFillable = true; // indicates that this type can be pre filled on init + } -MyStorageType.prototype.activate = function (options, done) { - // initialize code... - done && done(err); -}; + activate(options, done) { + // initialize code... + done && done(err); + } -MyStorageType.prototype.fill = function (playlists, done) { - // fill storage from playlists option in Server and Station class - done && done(err); -}; + fill(playlists, done) { + // fill storage from playlists option in Server and Station class + done && done(err); + } -MyStorageType.prototype.findAll = function (done) { - // findAll code... - done && done(err, playlists); -}; + findAll(done) { + // findAll code... + done && done(err, playlists); + } -MyStorageType.prototype.insert = function (playlist, done) { - // insert code... - done && done(err); -}; + insert(playlist, done) { + // insert code... + done && done(err); + } -MyStorageType.prototype.update = function (playlist, done) { - // update code... - done && done(err); -}; + update(playlist, done) { + // update code... + done && done(err); + } -MyStorageType.prototype.remove = function (playlistId, done) { - // remove code... - done && done(err); -}; + remove(playlistId, done) { + // remove code... + done && done(err); + } +} Storage.registerType("MyStorage", new MyStorageType()); -new Server({ +jscast({ stationOptions: { storageType: "MyStorage" } -}).listen(); +}) +.start() +.catch((err) => console.error(err)); ``` ## TODO -- API -- Auth +- API Documentation +- Authentication +- Change async to Promise ## License diff --git a/demo/index.js b/demo/index.js index 03db15f..536f5ff 100644 --- a/demo/index.js +++ b/demo/index.js @@ -1,7 +1,10 @@ import { log } from "util"; -import Server from "../src"; +import { + default as jscast, + PluginManager +} from "../src"; import geoip from "geoip-lite"; import ip from "ip"; @@ -28,20 +31,31 @@ const suicidePlaylist = [ "https://www.youtube.com/watch?v=7S8t_LfA3y0" ].map(mapYouTubeList); -new Server({ - manageRootPath: "/", - icyServerRootPath: "/listen", - stationOptions: { - ffmpegPath: "C:/projects/ffmpeg/bin/ffmpeg.exe", - storageType: "Memory", - playlists: [ - yogscastPlaylist, - suicidePlaylist - ] - } - }) - .on("error", (err) => { - console.error(err); +const jscastOptions = { + // manageRootPath: "/", + // icyServerRootPath: "/listen", + stationOptions: { + // ffmpegPath: "C:/projects/ffmpeg/bin/ffmpeg.exe", + storageType: "Memory", + playlists: [ + yogscastPlaylist, + suicidePlaylist + ] + } +}; + +const instance = jscast(jscastOptions) + .on("clientRejected", (client) => { + log(`client ${client.ip} rejected`); + }); + +const icyServer = instance.pluginManager.getActiveType("IcyServer"); +const manage = instance.pluginManager.getActiveType("Manage"); + +instance + .station + .on("play", (item, metadata) => { + log(`playing ${metadata.options.StreamTitle}`); }) .on("nothingToPlay", (playlist) => { if (!playlist) { @@ -49,21 +63,31 @@ new Server({ } else { log("playlist is empty"); } + }); + +instance + .start({ + port: 8000 }) - .on("play", (item, metadata) => { - log(`playing ${metadata.options.StreamTitle}`); - }) - .on("clientRejected", (client) => { - log(`client ${client.ip} rejected`); - }) - .on("icyServerClientConnect", (client) => { - log(`client ${client.ip} connected`); - }) - .on("icyServerClientDisconnect", (client) => { - log(`client ${client.ip} disconnected`); + .then(() => { + log(`jscast is running`); + + if (icyServer) { + icyServer + .on("clientConnect", (client) => { + log(`icy client ${client.ip} connected`); + }) + .on("clientDisconnect", (client) => { + log(`icy client ${client.ip} disconnected`); + }); + + log(`listen on http://localhost:${icyServer.port}${icyServer.rootPath}`); + } + + if (manage) { + log(`manage on http://localhost:${manage.port}${manage.rootPath}`); + } }) - .listen(8888, (server) => { - log(`jscast server is running`); - log(`listen on http://localhost:${server.port}${server.icyServerRootPath}`); - log(`manage on http://localhost:${server.port}${server.manageRootPath}`); + .catch((err) => { + console.error(err); }); diff --git a/dist/big-buffer/index.js b/dist/big-buffer/index.js deleted file mode 100644 index 5f2ddc8..0000000 --- a/dist/big-buffer/index.js +++ /dev/null @@ -1,45 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _events = require("events"); - -class BigBuffer extends _events.EventEmitter { - constructor(options) { - super(); - - options = options || {}; - - this.minDataLength = options.minDataLength || 1024 * 1; - this.maxDataLength = options.maxDataLength || 1024 * 1024 * 200; - this.buffer = new Buffer([]); - } - - bufferData(buffer) { - this.buffer = Buffer.concat([this.buffer, buffer]); - - if (this.buffer.length > this.maxDataLength) { - const bytesToRemove = this.buffer.length - this.maxDataLength; - console.log("big buffer is full, shifting " + bytesToRemove + " bytes"); - this.buffer = this.buffer.slice(bytesToRemove); - } - } - - hasEnoughData() { - return this.minDataLength <= this.buffer.length; - } - - getData(length) { - const buffer = this.buffer.slice(this.buffer.length - length, length); - this.buffer = this.buffer.slice(0, this.buffer.length - length); - - if (!this.hasEnoughData()) { - this.emit("needMoreData"); - } - - return buffer; - } -} -exports.default = BigBuffer; \ No newline at end of file diff --git a/dist/cli.js b/dist/cli.js index c6df5c3..8d81e2b 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -18,27 +18,27 @@ var _package = require("../package"); var _package2 = _interopRequireDefault(_package); -var _server = require("./server"); +var _ = require("./"); -var _server2 = _interopRequireDefault(_server); +var _2 = _interopRequireDefault(_); var _storage = require("./storage"); var _storage2 = _interopRequireDefault(_storage); +var _plugins = require("./plugins"); + +var _plugins2 = _interopRequireDefault(_plugins); + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } -const storageTypeNames = _storage2.default.getTypeNames(); +const allStorageTypeNames = _storage2.default.getTypeNames(); +const allPluginTypeNames = _plugins2.default.getTypeNames(); -_commander2.default.version(_package2.default.version).option("-p, --port [port]", "sets server port", parseInt).option("-s, --storage-type [storageType]", "use storage type, built-in types: " + storageTypeNames.join(", ")).option("--ffmpeg-path [ffmpegPath]", "path to ffmpeg binary").option("--youtube-items [youtubeItems]", "youtube items to play", parseList).option("--whitelist [whitelist]", "country whitelist e.g. US,DE", parseList).option("--blacklist [blacklist]", "country blacklist e.g. FR,IT", parseList).parse(process.argv); +_commander2.default.version(_package2.default.version).option("-p, --port [port]", "sets server port", parseInt).option("-s, --storage-type [storageType]", "use storage type, built-in types: " + allStorageTypeNames.join(", ")).option("-t, --plugin-types [pluginTypes]", "use plugin types, built-in types: " + allPluginTypeNames.join(", "), parseList).option("--ffmpeg-path [ffmpegPath]", "path to ffmpeg binary e.g. C:/ffmpeg.exe").option("--youtube-items [youtubeItems]", "youtube items to play e.g. URL1,URL2", parseList).option("--whitelist [whitelist]", "country whitelist e.g. US,DE", parseList).option("--blacklist [blacklist]", "country blacklist e.g. FR,IT", parseList).parse(process.argv); const whitelist = _commander2.default.whitelist; const blacklist = _commander2.default.blacklist; - -function isInCountryList(geo, list) { - return geo && list && list.length && list.some(country => country === geo.country); -} - const playlists = []; const playlist = (_commander2.default.youtubeItems || []).map(item => mapYouTubeList(item)); @@ -46,40 +46,61 @@ if (playlist.length) { playlists.push(playlist); } -new _server2.default({ - allow: client => { - // TODO: include in jscast server - if (_ip2.default.isEqual(client.ip, "127.0.0.1") || client.ip === "::1") return true; - if ((!whitelist || !whitelist.length) && (!blacklist || !blacklist.length)) return true; - - const geo = _geoipLite2.default.lookup(client.ip); - return isInCountryList(geo, whitelist) && !isInCountryList(geo, blacklist); - }, +const jscastOptions = { stationOptions: { storageType: _commander2.default.storageType, ffmpegPath: _commander2.default.ffmpegPath, playlists: playlists + }, + pluginManagerOptions: { + types: _commander2.default.pluginTypes } -}).on("error", err => { - console.error(err); +}; + +const instance = (0, _2.default)(jscastOptions).on("clientRejected", client => { + (0, _util.log)(`client ${ client.ip } rejected`); +}); + +const icyServer = instance.pluginManager.getActiveType("IcyServer"); +const manage = instance.pluginManager.getActiveType("Manage"); + +instance.station.on("play", (item, metadata) => { + (0, _util.log)(`playing ${ metadata.options.StreamTitle }`); }).on("nothingToPlay", playlist => { if (!playlist) { (0, _util.log)("no playlist"); } else { (0, _util.log)("playlist is empty"); } -}).on("play", (item, metadata) => { - (0, _util.log)(`playing ${ metadata.options.StreamTitle }`); -}).on("clientRejected", client => { - (0, _util.log)(`client ${ client.ip } rejected`); -}).on("icyServerClientConnect", client => { - (0, _util.log)(`client ${ client.ip } connected`); -}).on("icyServerClientDisconnect", client => { - (0, _util.log)(`client ${ client.ip } disconnected`); -}).listen(_commander2.default.port, server => { - (0, _util.log)(`jscast server is running`); - (0, _util.log)(`listen on http://localhost:${ server.port }${ server.icyServerRootPath }`); - (0, _util.log)(`manage on http://localhost:${ server.port }${ server.manageRootPath }`); +}); + +instance.start({ + port: _commander2.default.port, + allow: client => { + if (_ip2.default.isEqual(client.ip, "127.0.0.1") || client.ip === "::1") return true; + if ((!whitelist || !whitelist.length) && (!blacklist || !blacklist.length)) return true; + + const geo = _geoipLite2.default.lookup(client.ip); + return isInCountryList(geo, whitelist) && !isInCountryList(geo, blacklist); + } +}).then(() => { + (0, _util.log)(`jscast is running`); + + if (icyServer) { + icyServer.on("clientConnect", client => { + (0, _util.log)(`icy client ${ client.ip } connected`); + }).on("clientDisconnect", client => { + (0, _util.log)(`icy client ${ client.ip } disconnected`); + }); + + (0, _util.log)(`listen on http://localhost:${ icyServer.port }${ icyServer.rootPath }`); + } + + if (manage) { + (0, _util.log)(`manage on http://localhost:${ manage.port }${ manage.rootPath }`); + } +}).catch(err => { + console.error(err); }); function mapYouTubeList(url) { @@ -91,6 +112,10 @@ function mapYouTubeList(url) { }; } +function isInCountryList(geo, list) { + return geo && list && list.length && list.some(country => country === geo.country); +} + function parseList(data) { return (data || "").split(","); } \ No newline at end of file diff --git a/dist/client/client-allow-middleware.js b/dist/client/client-allow-middleware.js index fdc88f7..a391e87 100644 --- a/dist/client/client-allow-middleware.js +++ b/dist/client/client-allow-middleware.js @@ -9,6 +9,7 @@ exports.default = function (allow, rejected) { const client = req.jscastClient; if (client) { if (!allow(client)) { + // TODO: allow promise rejected(client); return res.sendStatus(404); } else { diff --git a/dist/index.js b/dist/index.js index a107fdd..98b2bf6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3,7 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true }); -exports.Item = exports.Playlist = exports.Storage = exports.Server = exports.Station = exports.Stream = undefined; +exports.Item = exports.Playlist = exports.PluginManager = exports.Storage = exports.Station = exports.Stream = exports.JsCast = exports.jscast = undefined; + +var _events = require("events"); + +var _http = require("http"); + +var _express = require("express"); + +var _express2 = _interopRequireDefault(_express); var _stream = require("./stream"); @@ -13,14 +21,14 @@ var _station = require("./station"); var _station2 = _interopRequireDefault(_station); -var _server = require("./server"); - -var _server2 = _interopRequireDefault(_server); - var _storage = require("./storage"); var _storage2 = _interopRequireDefault(_storage); +var _plugins = require("./plugins"); + +var _plugins2 = _interopRequireDefault(_plugins); + var _playlist = require("./playlist"); var _playlist2 = _interopRequireDefault(_playlist); @@ -29,12 +37,104 @@ var _item = require("./item"); var _item2 = _interopRequireDefault(_item); +var _clientMiddleware = require("./client/client-middleware"); + +var _clientMiddleware2 = _interopRequireDefault(_clientMiddleware); + +var _clientAllowMiddleware = require("./client/client-allow-middleware"); + +var _clientAllowMiddleware2 = _interopRequireDefault(_clientAllowMiddleware); + +var _package = require("../package"); + +var _package2 = _interopRequireDefault(_package); + function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } +class JsCast extends _events.EventEmitter { + constructor(options) { + super(); + + options = options || {}; + + this.stationOptions = options.stationOptions || {}; + this.station = options.station || new _station2.default(this.stationOptions); + + this.pluginManagerOptions = options.pluginManagerOptions || {}; + this.pluginManager = new _plugins2.default(this.pluginManagerOptions); + } + + start(options) { + options = options || {}; + + this.app = options.app || (0, _express2.default)(); + this.socket = options.socket || new _http.Server(this.app); + this.port = options.port || 8000; + this.allow = options.allow || function () { + return true; + }; + + this.station = options.station || this.station; + this.pluginManager = options.pluginManager || this.pluginManager; + + // TODO: universal (client) middlewares + this.app.use((req, res, next) => { + res.setHeader("x-powered-by", `jscast v${ _package2.default.version } https://github.com/BigTeri/jscast`); + next(); + }); + this.app.use(_clientMiddleware2.default); + this.app.use((0, _clientAllowMiddleware2.default)(this.allow, client => { + this.emit("clientRejected", client); + })); + + return this.pluginManager.activate(this).then(options => { + return new Promise(resolve => { + if (options.socket && options.port) { + // TODO: listen to socket + this.listen(options.socket, options.port, () => { + resolve(); + }); + } else { + resolve(); + } + }); + }).then(() => { + this.station.start(); // TODO: promises + + return this; + }); + } + + listen(socket, port, done) { + if (typeof port === "function") { + done = port; + port = null; + } + + port = this.port = port || this.port; + + this.once("start", () => { + done && done(); + }); + + socket.listen(port, () => { + this.emit("start"); + }); + + return socket; + } +} + +function jscast(options) { + return new JsCast(options); +} + +exports.jscast = jscast; +exports.JsCast = JsCast; exports.Stream = _stream2.default; exports.Station = _station2.default; -exports.Server = _server2.default; exports.Storage = _storage2.default; +exports.PluginManager = _plugins2.default; exports.Playlist = _playlist2.default; exports.Item = _item2.default; -exports.default = _server2.default; \ No newline at end of file +exports.default = jscast; \ No newline at end of file diff --git a/dist/output/index.js b/dist/output/index.js new file mode 100644 index 0000000..a0c9e25 --- /dev/null +++ b/dist/output/index.js @@ -0,0 +1,74 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _icyServer = require("./types/icy-server"); + +var _icyServer2 = _interopRequireDefault(_icyServer); + +var _speaker = require("./types/speaker"); + +var _speaker2 = _interopRequireDefault(_speaker); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const typePluginMap = {}; + +class OutputManager { + constructor(options) { + options = options || {}; + + this.types = options.types || ["IcyServer", "Speaker"]; + if (this.types.length < 1) throw new Error("No output types"); + + this.typePlugins = this.types.map(type => OutputManager.getType(type)); + } + + activate(options) { + options = options || {}; + + const promises = this.typePlugins.map(typePlugin => { + const pluginOptions = options[typePlugin.typeName] || {}; + pluginOptions.app = pluginOptions.app || options.app; + pluginOptions.socket = pluginOptions.socket || options.socket; + pluginOptions.port = pluginOptions.port || options.port; + pluginOptions.station = pluginOptions.station || options.station; + pluginOptions.outputManager = pluginOptions.outputManager || options.outputManager || this; + + return Promise.resolve(typePlugin.activate(pluginOptions)).then(() => { + if (typePlugin.app) { + pluginOptions.app = pluginOptions.app || typePlugin.app; + options.app = pluginOptions.app; + } + + if (typePlugin.socket) { + pluginOptions.socket = pluginOptions.socket || typePlugin.socket; + options.socket = pluginOptions.socket; + } + }); + }); + + return Promise.all(promises).then(() => { + return options; + }); + } + + static registerType(type, typePlugin) { + typePlugin.typeName = type; + typePluginMap[type] = typePlugin; + } + + static getType(type) { + return typePluginMap[type]; + } + + static getTypeNames() { + return Object.keys(typePluginMap); + } +} + +exports.default = OutputManager; +OutputManager.registerType("IcyServer", new _icyServer2.default()); +OutputManager.registerType("Speaker", new _speaker2.default()); \ No newline at end of file diff --git a/dist/icy-server/index.js b/dist/output/types/icy-server/index.js similarity index 90% rename from dist/icy-server/index.js rename to dist/output/types/icy-server/index.js index 8e591c2..a012791 100644 --- a/dist/icy-server/index.js +++ b/dist/output/types/icy-server/index.js @@ -12,21 +12,20 @@ var _express = require("express"); var _express2 = _interopRequireDefault(_express); -var _station = require("../station"); +var _station = require("../../../station"); var _station2 = _interopRequireDefault(_station); -var _client = require("../client"); +var _client = require("../../../client"); var _client2 = _interopRequireDefault(_client); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } class IcyServer extends _events.EventEmitter { - constructor(options) { - super(); - + activate(options) { options = options || {}; + this.name = options.name || "jscast - A SHOUTcast Server/Library written in JavaScript"; this.url = options.url || "https://github.com/BigTeri/jscast"; this.genre = options.genre || "Music"; @@ -40,20 +39,19 @@ class IcyServer extends _events.EventEmitter { this.station = options.station || new _station2.default(this.stationOptions); this.app = options.app || (0, _express2.default)(); this.socket = options.socket || new _http.Server(this.app); + this.port = options.port || 8000; this.station.on("data", (data, metadata) => { if (data) { let metadataBuffer = data; + if (!this.skipMetadata) { metadataBuffer = metadata.createCombinedBuffer(data); } + this.clients.forEach(client => { const sendMetadata = !this.skipMetadata && client.wantsMetadata; - if (sendMetadata) { - client.write(metadataBuffer); - } else { - client.write(data); - } + client.write(sendMetadata ? metadataBuffer : data); }); } }); diff --git a/dist/output/types/speaker/index.js b/dist/output/types/speaker/index.js new file mode 100644 index 0000000..790420b --- /dev/null +++ b/dist/output/types/speaker/index.js @@ -0,0 +1,40 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _lame = require("lame"); + +var _lame2 = _interopRequireDefault(_lame); + +var _speaker = require("speaker"); + +var _speaker2 = _interopRequireDefault(_speaker); + +var _station = require("../../../station"); + +var _station2 = _interopRequireDefault(_station); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +class SpeakerType { + activate(options) { + options = options || {}; + + this.stationOptions = options.stationOptions || {}; + this.station = options.station || new _station2.default(this.stationOptions); + + this.decoder = options.decoder || new _lame2.default.Decoder(); + this.speaker = options.speaker || new _speaker2.default(); + + this.station.on("data", data => { + if (data && data.length) { + this.decoder.write(data); + } + }); + + this.decoder.pipe(this.speaker); + } +} +exports.default = SpeakerType; \ No newline at end of file diff --git a/dist/plugins/icy-server/index.js b/dist/plugins/icy-server/index.js new file mode 100644 index 0000000..ee49140 --- /dev/null +++ b/dist/plugins/icy-server/index.js @@ -0,0 +1,89 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _events = require("events"); + +var _http = require("http"); + +var _express = require("express"); + +var _express2 = _interopRequireDefault(_express); + +var _station = require("../../station"); + +var _station2 = _interopRequireDefault(_station); + +var _client = require("../../client"); + +var _client2 = _interopRequireDefault(_client); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +class IcyServer extends _events.EventEmitter { + activate(options) { + options = options || {}; + + this.name = options.name || "jscast - A SHOUTcast Server/Library written in JavaScript"; + this.url = options.url || "https://github.com/BigTeri/jscast"; + this.genre = options.genre || "Music"; + this.isPublic = options.isPublic || false; + this.bitrate = options.bitrate || 128; + this.bufferSize = options.bufferSize || 8192; + this.skipMetadata = options.skipMetadata || false; + this.rootPath = options.rootPath || "/"; + + this.stationOptions = options.stationOptions || {}; + this.station = options.station || new _station2.default(this.stationOptions); + this.app = options.app || (0, _express2.default)(); + this.socket = options.socket || new _http.Server(this.app); + this.port = options.port || 8000; + + this.station.on("data", (data, metadata) => { + if (data) { + let metadataBuffer = data; + + if (!this.skipMetadata) { + metadataBuffer = metadata.createCombinedBuffer(data); + } + + this.clients.forEach(client => { + const sendMetadata = !this.skipMetadata && client.wantsMetadata; + client.write(sendMetadata ? metadataBuffer : data); + }); + } + }); + + this.clients = []; + this.app.get(this.rootPath, (req, res) => this.clientConnected(new _client2.default(req, res))); + } + + clientConnected(client) { + this.clients.push(client); + this.emit("clientConnect", client); + + client.res.writeHead(200, this.getHeaders(client)); + client.req.once("close", this.clientDisconnected.bind(this, client)); + } + + clientDisconnected(client) { + this.clients.splice(this.clients.indexOf(client), 1); + this.emit("clientDisconnect", client); + } + + getHeaders(client) { + const sendMetadata = !this.skipMetadata && client.wantsMetadata; + return { + "Content-Type": "audio/mpeg", + "icy-name": this.name, + "icy-url": this.url, + "icy-genre": this.genre, + "icy-pub": this.isPublic ? "1" : "0", + "icy-br": this.bitrate.toString(), + "icy-metaint": sendMetadata ? this.bufferSize.toString() : "0" + }; + } +} +exports.default = IcyServer; \ No newline at end of file diff --git a/dist/plugins/index.js b/dist/plugins/index.js new file mode 100644 index 0000000..55d8984 --- /dev/null +++ b/dist/plugins/index.js @@ -0,0 +1,84 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _manage = require("./manage"); + +var _manage2 = _interopRequireDefault(_manage); + +var _icyServer = require("./icy-server"); + +var _icyServer2 = _interopRequireDefault(_icyServer); + +var _speaker = require("./speaker"); + +var _speaker2 = _interopRequireDefault(_speaker); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const typePluginMap = {}; + +class PluginManager { + constructor(options) { + options = options || {}; + + this.types = options.types || ["Manage", "IcyServer", "Speaker"]; + this.typePlugins = this.types.map(type => PluginManager.getType(type)); + } + + activate(options) { + options = options || {}; + + const promises = this.typePlugins.map(typePlugin => { + const pluginOptions = options[typePlugin.typeName] || {}; + pluginOptions.app = pluginOptions.app || options.app; + pluginOptions.socket = pluginOptions.socket || options.socket; + pluginOptions.port = pluginOptions.port || options.port; + pluginOptions.station = pluginOptions.station || options.station; + + return Promise.resolve(typePlugin.activate(pluginOptions)).then(() => { + if (typePlugin.app) { + pluginOptions.app = pluginOptions.app || typePlugin.app; + options.app = pluginOptions.app; + } + + if (typePlugin.socket) { + pluginOptions.socket = pluginOptions.socket || typePlugin.socket; + options.socket = pluginOptions.socket; + } + }); + }); + + return Promise.all(promises).then(() => { + return options; + }); + } + + isActive(type) { + return this.types.indexOf(type) > -1; + } + + getActiveType(type) { + return this.isActive(type) && PluginManager.getType(type); + } + + static registerType(type, typePlugin) { + typePlugin.typeName = type; + typePluginMap[type] = typePlugin; + } + + static getType(type) { + return typePluginMap[type]; + } + + static getTypeNames() { + return Object.keys(typePluginMap); + } +} + +exports.default = PluginManager; +PluginManager.registerType("Manage", new _manage2.default()); +PluginManager.registerType("IcyServer", new _icyServer2.default()); +PluginManager.registerType("Speaker", new _speaker2.default()); \ No newline at end of file diff --git a/dist/manage/index.js b/dist/plugins/manage/index.js similarity index 76% rename from dist/manage/index.js rename to dist/plugins/manage/index.js index 52d25fb..bf7d5ed 100644 --- a/dist/manage/index.js +++ b/dist/plugins/manage/index.js @@ -20,24 +20,23 @@ var _socket = require("socket.io"); var _socket2 = _interopRequireDefault(_socket); -var _station = require("../station"); +var _station = require("../../station"); var _station2 = _interopRequireDefault(_station); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } class Manage extends _events.EventEmitter { - constructor(options) { - super(); - + activate(options) { options = options || {}; this.app = options.app || (0, _express2.default)(); this.socket = options.socket || new _http.Server(this.app); + this.port = options.port || 8000; this.rootPath = options.rootPath || "/manage"; this.playerSourcePath = options.playerSourcePath || "/"; - this.staticFolderPath = options.staticFolderPath || _path2.default.join(__dirname, "../../", "./manage"); - this.jspmPath = options.jspmPath || _path2.default.join(__dirname, "../../"); + this.staticFolderPath = options.staticFolderPath || _path2.default.join(__dirname, "../../../", "./manage"); + this.jspmPath = options.jspmPath || _path2.default.join(__dirname, "../../../"); this.jspmPackagesPath = this.jspmPackagesPath || _path2.default.join(this.jspmPath, "./jspm_packages"); this.jspmConfigPath = this.jspmConfigPath || _path2.default.join(this.jspmPath, "./config.js"); this.stationOptions = options.stationOptions || {}; @@ -53,19 +52,19 @@ class Manage extends _events.EventEmitter { this.app.use("/jspm_packages", this.jspmRouter); this.app.get("/config.js", (req, res) => res.sendFile(fixWindowsPath(this.jspmConfigPath))); - // TODO: allow for socket.io this.webSocketClients = []; + // TODO: allow for socket.io this.io = (0, _socket2.default)(this.socket, { path: fixWindowsPath(_path2.default.join("/", this.rootPath, "/sockets")) - }).on("connection", socket => { - this.webSocketClients.push(socket); - this.emit("webSocketClientConnect", socket); + }).on("connection", clientSocket => { + this.webSocketClients.push(clientSocket); + this.emit("webSocketClientConnect", clientSocket); - socket.once("disconnect", () => { - this.webSocketClients.splice(this.webSocketClients.indexOf(socket), 1); - this.emit("webSocketClientDisconnect", socket); + clientSocket.once("disconnect", () => { + this.webSocketClients.splice(this.webSocketClients.indexOf(clientSocket), 1); + this.emit("webSocketClientDisconnect", clientSocket); }).on("fetch", () => { - socket.emit("info", { + clientSocket.emit("info", { item: this.station.item, metadata: this.station.metadata, playlists: this.station.playlists, @@ -90,24 +89,24 @@ class Manage extends _events.EventEmitter { }); this.station.on("play", (item, metadata) => { - this.webSocketClients.forEach(socket => { - socket.emit("playing", item, metadata); + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("playing", item, metadata); }); }).on("playlistCreated", playlist => { - this.webSocketClients.forEach(socket => { - socket.emit("playlistCreated", playlist); + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("playlistCreated", playlist); }); }).on("itemCreated", (item, playlist) => { - this.webSocketClients.forEach(socket => { - socket.emit("itemCreated", item, playlist._id); + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("itemCreated", item, playlist._id); }); }).on("itemRemoved", (item, playlist) => { - this.webSocketClients.forEach(socket => { - socket.emit("itemRemoved", item._id, playlist._id); + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("itemRemoved", item._id, playlist._id); }); }).on("playlistRemoved", playlist => { - this.webSocketClients.forEach(socket => { - socket.emit("playlistRemoved", playlist._id); + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("playlistRemoved", playlist._id); }); }); } diff --git a/dist/plugins/speaker/index.js b/dist/plugins/speaker/index.js new file mode 100644 index 0000000..02aa0b5 --- /dev/null +++ b/dist/plugins/speaker/index.js @@ -0,0 +1,40 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _lame = require("lame"); + +var _lame2 = _interopRequireDefault(_lame); + +var _speaker = require("speaker"); + +var _speaker2 = _interopRequireDefault(_speaker); + +var _station = require("../../station"); + +var _station2 = _interopRequireDefault(_station); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +class SpeakerType { + activate(options) { + options = options || {}; + + this.stationOptions = options.stationOptions || {}; + this.station = options.station || new _station2.default(this.stationOptions); + + this.decoder = options.decoder || new _lame2.default.Decoder(); + this.speaker = options.speaker || new _speaker2.default(); + + this.station.on("data", data => { + if (data && data.length) { + this.decoder.write(data); + } + }); + + this.decoder.pipe(this.speaker); + } +} +exports.default = SpeakerType; \ No newline at end of file diff --git a/dist/server/index.js b/dist/server/index.js deleted file mode 100644 index 6ebd986..0000000 --- a/dist/server/index.js +++ /dev/null @@ -1,104 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _events = require("events"); - -var _http = require("http"); - -var _express = require("express"); - -var _express2 = _interopRequireDefault(_express); - -var _station = require("../station"); - -var _station2 = _interopRequireDefault(_station); - -var _icyServer = require("../icy-server"); - -var _icyServer2 = _interopRequireDefault(_icyServer); - -var _manage = require("../manage"); - -var _manage2 = _interopRequireDefault(_manage); - -var _clientMiddleware = require("../client/client-middleware"); - -var _clientMiddleware2 = _interopRequireDefault(_clientMiddleware); - -var _clientAllowMiddleware = require("../client/client-allow-middleware"); - -var _clientAllowMiddleware2 = _interopRequireDefault(_clientAllowMiddleware); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -class Server extends _events.EventEmitter { - constructor(options) { - super(); - - this.icyServerRootPath = options.icyServerRootPath || "/"; - this.manageRootPath = options.manageRootPath || "/manage"; - this.stationOptions = options.stationOptions || {}; - this.station = options.station || new _station2.default(this.stationOptions); - this.app = options.app || (0, _express2.default)(); - this.socket = options.socket || new _http.Server(this.app); - this.port = options.port || 8000; - this.allow = options.allow || function () { - return true; - }; - - this.station.on("error", err => this.emit("error", err)); - this.station.on("play", (item, metadata) => this.emit("play", item, metadata)); - this.station.on("nothingToPlay", playlist => this.emit("nothingToPlay", playlist)); - - // TODO: universal (client) middlewares - this.app.use((req, res, next) => { - res.setHeader("x-powered-by", "jscast https://github.com/BigTeri/jscast"); - next(); - }); - this.app.use(_clientMiddleware2.default); - this.app.use((0, _clientAllowMiddleware2.default)(this.allow, client => { - this.emit("clientRejected", client); - })); - - this.icyServerOptions = options.icyServerOptions || {}; - this.icyServerOptions.rootPath = this.icyServerOptions.rootPath || this.icyServerRootPath; - this.icyServerOptions.socket = this.icyServerOptions.socket || this.socket; - this.icyServerOptions.app = this.icyServerOptions.app || this.app; - this.icyServerOptions.station = this.icyServerOptions.station || this.station; - this.icyServer = options.icyServer || new _icyServer2.default(this.icyServerOptions); - this.icyServer.on("clientConnect", client => this.emit("icyServerClientConnect", client)); - this.icyServer.on("clientDisconnect", client => this.emit("icyServerClientDisconnect", client)); - - this.manageOptions = options.manageOptions || {}; - this.manageOptions.rootPath = this.manageOptions.rootPath || this.manageRootPath; - this.manageOptions.playerSourcePath = this.manageOptions.playerSourcePath || this.icyServerRootPath; - this.manageOptions.socket = this.manageOptions.socket || this.socket; - this.manageOptions.app = this.manageOptions.app || this.app; - this.manageOptions.station = this.manageOptions.station || this.station; - this.manage = options.manage || new _manage2.default(this.manageOptions); - this.manage.on("webSocketClientConnect", client => this.emit("manageWebSocketClientConnect", client)); - this.manage.on("webSocketClientDisconnect", client => this.emit("manageWebSocketClientDisconnect", client)); - } - - listen(port, done) { - if (typeof port === "function") { - done = port; - port = null; - } - - port = this.port = port || this.port; - - this.once("start", () => { - this.station.start(); - done && done(this); - }); - - this.socket.listen(port, () => { - this.emit("start"); - }); - } -} -exports.default = Server; \ No newline at end of file diff --git a/dist/station/index.js b/dist/station/index.js index eace62a..69ee7c2 100644 --- a/dist/station/index.js +++ b/dist/station/index.js @@ -26,10 +26,6 @@ var _metadata = require("./metadata"); var _metadata2 = _interopRequireDefault(_metadata); -var _virtualPlayer = require("./virtual-player"); - -var _virtualPlayer2 = _interopRequireDefault(_virtualPlayer); - var _destroy = require("destroy"); var _destroy2 = _interopRequireDefault(_destroy); @@ -51,7 +47,6 @@ class Station extends _events.EventEmitter { this.ffmpegPath && _fluentFfmpeg2.default.setFfmpegPath(this.ffmpegPath); this.storage = new _storage2.default(this.storageType); - this.virtualPlayer = new _virtualPlayer2.default(); this.itemId = null; this.item = null; @@ -318,6 +313,7 @@ class Station extends _events.EventEmitter { if (options.streamNeedsPostProcessing) { stream = (0, _fluentFfmpeg2.default)(stream).audioBitrate(this.postProcessingBitRate).format("mp3"); } + return this.handleStreamError(stream); } } diff --git a/dist/station/virtual-player.js b/dist/station/virtual-player.js deleted file mode 100644 index 86642a4..0000000 --- a/dist/station/virtual-player.js +++ /dev/null @@ -1,9 +0,0 @@ -"use strict"; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -class VirtualPlayer { - constructor() {} -} -exports.default = VirtualPlayer; \ No newline at end of file diff --git a/dist/stream/index.js b/dist/stream/index.js index 7262b9a..91efe17 100644 --- a/dist/stream/index.js +++ b/dist/stream/index.js @@ -10,10 +10,6 @@ var _info = require("./info"); var _info2 = _interopRequireDefault(_info); -var _bigBuffer = require("../big-buffer"); - -var _bigBuffer2 = _interopRequireDefault(_bigBuffer); - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } class Stream extends _events.EventEmitter { @@ -26,7 +22,6 @@ class Stream extends _events.EventEmitter { this.dataInterval = options.dataInterval || 500; this.needMoreData = options.needMoreData || function () {}; this.streamInfos = []; - this.bigBuffer = new _bigBuffer2.default(); } start() { diff --git a/dist/web-ui/index.js b/dist/web-ui/index.js new file mode 100644 index 0000000..a0b6df6 --- /dev/null +++ b/dist/web-ui/index.js @@ -0,0 +1,68 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _manage = require("./types/manage"); + +var _manage2 = _interopRequireDefault(_manage); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const typePluginMap = {}; + +class WebUI { + constructor(options) { + options = options || {}; + + this.types = options.types || ["Manage"]; + if (this.types.length < 1) throw new Error("No output types"); + + this.typePlugins = this.types.map(type => WebUI.getType(type)); + } + + activate(options) { + options = options || {}; + + const promises = this.typePlugins.map(typePlugin => { + const pluginOptions = options[typePlugin.typeName] || {}; + pluginOptions.app = pluginOptions.app || options.app; + pluginOptions.socket = pluginOptions.socket || options.socket; + pluginOptions.port = pluginOptions.port || options.port; + pluginOptions.station = pluginOptions.station || options.station; + + return Promise.resolve(typePlugin.activate(pluginOptions)).then(() => { + if (typePlugin.app) { + pluginOptions.app = pluginOptions.app || typePlugin.app; + options.app = pluginOptions.app; + } + + if (typePlugin.socket) { + pluginOptions.socket = pluginOptions.socket || typePlugin.socket; + options.socket = pluginOptions.socket; + } + }); + }); + + return Promise.all(promises).then(() => { + return options; + }); + } + + static registerType(type, typePlugin) { + typePlugin.typeName = type; + typePluginMap[type] = typePlugin; + } + + static getType(type) { + return typePluginMap[type]; + } + + static getTypeNames() { + return Object.keys(typePluginMap); + } +} + +exports.default = WebUI; +WebUI.registerType("Manage", new _manage2.default()); \ No newline at end of file diff --git a/dist/web-ui/types/manage/index.js b/dist/web-ui/types/manage/index.js new file mode 100644 index 0000000..8e2c049 --- /dev/null +++ b/dist/web-ui/types/manage/index.js @@ -0,0 +1,118 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _events = require("events"); + +var _http = require("http"); + +var _path = require("path"); + +var _path2 = _interopRequireDefault(_path); + +var _express = require("express"); + +var _express2 = _interopRequireDefault(_express); + +var _socket = require("socket.io"); + +var _socket2 = _interopRequireDefault(_socket); + +var _station = require("../../../station"); + +var _station2 = _interopRequireDefault(_station); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +class Manage extends _events.EventEmitter { + activate(options) { + options = options || {}; + + this.app = options.app || (0, _express2.default)(); + this.socket = options.socket || new _http.Server(this.app); + this.port = options.port || 8000; + this.rootPath = options.rootPath || "/manage"; + this.playerSourcePath = options.playerSourcePath || "/"; + this.staticFolderPath = options.staticFolderPath || _path2.default.join(__dirname, "../../../../", "./manage"); + this.jspmPath = options.jspmPath || _path2.default.join(__dirname, "../../../../"); + this.jspmPackagesPath = this.jspmPackagesPath || _path2.default.join(this.jspmPath, "./jspm_packages"); + this.jspmConfigPath = this.jspmConfigPath || _path2.default.join(this.jspmPath, "./config.js"); + this.stationOptions = options.stationOptions || {}; + this.station = options.station || new _station2.default(this.stationOptions); + + this.webRouter = new _express2.default.Router(); + this.webRouter.use(_express2.default.static(fixWindowsPath(this.staticFolderPath))); + this.webRouter.use("/jspm_packages", _express2.default.static(fixWindowsPath(this.jspmPackagesPath))); + this.app.use(fixWindowsPath(_path2.default.join("/", this.rootPath)), this.webRouter); + + this.jspmRouter = new _express2.default.Router(); + this.jspmRouter.use(_express2.default.static(fixWindowsPath(this.jspmPackagesPath))); + this.app.use("/jspm_packages", this.jspmRouter); + this.app.get("/config.js", (req, res) => res.sendFile(fixWindowsPath(this.jspmConfigPath))); + + this.webSocketClients = []; + // TODO: allow for socket.io + this.io = (0, _socket2.default)(this.socket, { + path: fixWindowsPath(_path2.default.join("/", this.rootPath, "/sockets")) + }).on("connection", clientSocket => { + this.webSocketClients.push(clientSocket); + this.emit("webSocketClientConnect", clientSocket); + + clientSocket.once("disconnect", () => { + this.webSocketClients.splice(this.webSocketClients.indexOf(clientSocket), 1); + this.emit("webSocketClientDisconnect", clientSocket); + }).on("fetch", () => { + clientSocket.emit("info", { + item: this.station.item, + metadata: this.station.metadata, + playlists: this.station.playlists, + playerSourcePath: this.playerSourcePath + }); + }).on("next", () => { + this.station.replaceNext(); + }).on("addItem", item => { + // TODO: item validation + this.station.addItem(item); + }).on("addPlaylist", () => { + this.station.addPlaylist(); + }).on("playItem", (id, playlistId) => { + this.station.replacePlaylistByPlaylistIdAndItemId(playlistId, id); + }).on("playPlaylist", playlistId => { + this.station.replacePlaylistByPlaylistId(playlistId); + }).on("removeItem", (id, playlistId) => { + this.station.removeItem(id, playlistId); + }).on("removePlaylist", playlistId => { + this.station.removePlaylist(playlistId); + }); + }); + + this.station.on("play", (item, metadata) => { + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("playing", item, metadata); + }); + }).on("playlistCreated", playlist => { + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("playlistCreated", playlist); + }); + }).on("itemCreated", (item, playlist) => { + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("itemCreated", item, playlist._id); + }); + }).on("itemRemoved", (item, playlist) => { + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("itemRemoved", item._id, playlist._id); + }); + }).on("playlistRemoved", playlist => { + this.webSocketClients.forEach(clientSocket => { + clientSocket.emit("playlistRemoved", playlist._id); + }); + }); + } +} + +exports.default = Manage; +function fixWindowsPath(url) { + return url.replace(/\\/g, "/"); +} \ No newline at end of file diff --git a/package.json b/package.json index e7edbdb..b5a994c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "jscast", - "version": "0.3.5", - "description": "A SHOUTcast Server/Library written in JavaScript", + "version": "0.4.0", + "description": "An Audio Streaming Application written in JavaScript", "author": "BigTeri", "license": "MIT", "repository": "https://github.com/BigTeri/jscast", @@ -16,10 +16,12 @@ "geoip-lite": "^1.1.8", "ip": "^1.1.3", "jspm": "^0.16.48", + "lame": "^1.2.4", "mkdirp": "^0.5.1", "shortid": "^2.2.6", "sm-parsers": "^0.1.2", "socket.io": "^1.4.8", + "speaker": "^0.3.0", "ytdl-core": "^0.7.17" }, "devDependencies": { @@ -29,7 +31,8 @@ "scripts": { "start": "babel-node demo --presets es2015-node", "build": "babel src --out-dir dist --presets es2015-node", - "debug-cli": "npm run build && node ./dist/cli.js -p 8000 --ffmpeg-path C:/projects/ffmpeg/bin/ffmpeg.exe -s Memory --youtube-items https://www.youtube.com/watch?v=ytWz0qVvBZ0,https://www.youtube.com/watch?v=D67jM8nO7Ag", + "debug-cli": "npm run build && node ./dist/cli.js -p 8000 -s Memory --youtube-items https://www.youtube.com/watch?v=ytWz0qVvBZ0,https://www.youtube.com/watch?v=D67jM8nO7Ag -t IcyServer,Manage", + "debug-cli-win": "npm run build && node ./dist/cli.js -p 8000 -s Memory --youtube-items https://www.youtube.com/watch?v=ytWz0qVvBZ0,https://www.youtube.com/watch?v=D67jM8nO7Ag --ffmpeg-path C:/projects/ffmpeg/bin/ffmpeg.exe", "postinstall": "jspm i --production" }, "bin": { diff --git a/src/big-buffer/index.js b/src/big-buffer/index.js deleted file mode 100644 index d44f81b..0000000 --- a/src/big-buffer/index.js +++ /dev/null @@ -1,40 +0,0 @@ -import { - EventEmitter -} from "events"; - -export default class BigBuffer extends EventEmitter { - constructor(options) { - super(); - - options = options || {}; - - this.minDataLength = options.minDataLength || 1024 * 1; - this.maxDataLength = options.maxDataLength || 1024 * 1024 * 200; - this.buffer = new Buffer([]); - } - - bufferData(buffer) { - this.buffer = Buffer.concat([this.buffer, buffer]); - - if (this.buffer.length > this.maxDataLength) { - const bytesToRemove = this.buffer.length - this.maxDataLength; - console.log("big buffer is full, shifting " + bytesToRemove + " bytes"); - this.buffer = this.buffer.slice(bytesToRemove); - } - } - - hasEnoughData() { - return this.minDataLength <= this.buffer.length; - } - - getData(length) { - const buffer = this.buffer.slice(this.buffer.length - length, length); - this.buffer = this.buffer.slice(0, this.buffer.length - length); - - if (!this.hasEnoughData()) { - this.emit("needMoreData"); - } - - return buffer; - } -} diff --git a/src/cli.js b/src/cli.js index 0b77ffa..678a3d8 100644 --- a/src/cli.js +++ b/src/cli.js @@ -5,28 +5,26 @@ import ip from "ip"; import geoip from "geoip-lite"; import program from "commander"; import pkg from "../package"; -import Server from "./server"; +import jscast from "./"; import Storage from "./storage"; +import PluginManager from "./plugins"; -const storageTypeNames = Storage.getTypeNames(); +const allStorageTypeNames = Storage.getTypeNames(); +const allPluginTypeNames = PluginManager.getTypeNames(); program .version(pkg.version) .option("-p, --port [port]", "sets server port", parseInt) - .option("-s, --storage-type [storageType]", "use storage type, built-in types: " + storageTypeNames.join(", ")) - .option("--ffmpeg-path [ffmpegPath]", "path to ffmpeg binary") - .option("--youtube-items [youtubeItems]", "youtube items to play", parseList) + .option("-s, --storage-type [storageType]", "use storage type, built-in types: " + allStorageTypeNames.join(", ")) + .option("-t, --plugin-types [pluginTypes]", "use plugin types, built-in types: " + allPluginTypeNames.join(", "), parseList) + .option("--ffmpeg-path [ffmpegPath]", "path to ffmpeg binary e.g. C:/ffmpeg.exe") + .option("--youtube-items [youtubeItems]", "youtube items to play e.g. URL1,URL2", parseList) .option("--whitelist [whitelist]", "country whitelist e.g. US,DE", parseList) .option("--blacklist [blacklist]", "country blacklist e.g. FR,IT", parseList) .parse(process.argv); const whitelist = program.whitelist; const blacklist = program.blacklist; - -function isInCountryList(geo, list) { - return geo && list && list.length && list.some((country) => country === geo.country); -} - const playlists = []; const playlist = (program.youtubeItems || []).map((item) => mapYouTubeList(item)); @@ -34,9 +32,42 @@ if (playlist.length) { playlists.push(playlist); } -new Server({ +const jscastOptions = { + stationOptions: { + storageType: program.storageType, + ffmpegPath: program.ffmpegPath, + playlists: playlists + }, + pluginManagerOptions: { + types: program.pluginTypes + } +}; + +const instance = jscast(jscastOptions) + .on("clientRejected", (client) => { + log(`client ${client.ip} rejected`); + }); + +const icyServer = instance.pluginManager.getActiveType("IcyServer"); +const manage = instance.pluginManager.getActiveType("Manage"); + +instance + .station + .on("play", (item, metadata) => { + log(`playing ${metadata.options.StreamTitle}`); + }) + .on("nothingToPlay", (playlist) => { + if (!playlist) { + log("no playlist"); + } else { + log("playlist is empty"); + } + }); + +instance + .start({ + port: program.port, allow: (client) => { - // TODO: include in jscast server if (ip.isEqual(client.ip, "127.0.0.1") || client.ip === "::1") return true; if ( (!whitelist || !whitelist.length) && @@ -45,39 +76,29 @@ new Server({ const geo = geoip.lookup(client.ip); return isInCountryList(geo, whitelist) && !isInCountryList(geo, blacklist); - }, - stationOptions: { - storageType: program.storageType, - ffmpegPath: program.ffmpegPath, - playlists: playlists } }) - .on("error", (err) => { - console.error(err); - }) - .on("nothingToPlay", (playlist) => { - if (!playlist) { - log("no playlist"); - } else { - log("playlist is empty"); + .then(() => { + log(`jscast is running`); + + if (icyServer) { + icyServer + .on("clientConnect", (client) => { + log(`icy client ${client.ip} connected`); + }) + .on("clientDisconnect", (client) => { + log(`icy client ${client.ip} disconnected`); + }); + + log(`listen on http://localhost:${icyServer.port}${icyServer.rootPath}`); + } + + if (manage) { + log(`manage on http://localhost:${manage.port}${manage.rootPath}`); } }) - .on("play", (item, metadata) => { - log(`playing ${metadata.options.StreamTitle}`); - }) - .on("clientRejected", (client) => { - log(`client ${client.ip} rejected`); - }) - .on("icyServerClientConnect", (client) => { - log(`client ${client.ip} connected`); - }) - .on("icyServerClientDisconnect", (client) => { - log(`client ${client.ip} disconnected`); - }) - .listen(program.port, (server) => { - log(`jscast server is running`); - log(`listen on http://localhost:${server.port}${server.icyServerRootPath}`); - log(`manage on http://localhost:${server.port}${server.manageRootPath}`); + .catch((err) => { + console.error(err); }); function mapYouTubeList(url) { @@ -89,6 +110,10 @@ function mapYouTubeList(url) { }; } +function isInCountryList(geo, list) { + return geo && list && list.length && list.some((country) => country === geo.country); +} + function parseList(data) { return (data || "").split(","); } diff --git a/src/client/client-allow-middleware.js b/src/client/client-allow-middleware.js index 9986975..9de450e 100644 --- a/src/client/client-allow-middleware.js +++ b/src/client/client-allow-middleware.js @@ -2,7 +2,7 @@ export default function (allow, rejected) { return function (req, res, next) { const client = req.jscastClient; if (client) { - if (!allow(client)) { + if (!allow(client)) { // TODO: allow promise rejected(client); return res.sendStatus(404); } else { diff --git a/src/index.js b/src/index.js index eb3bbc9..9e11453 100644 --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,110 @@ +import { + EventEmitter +} from "events"; +import { + Server as HttpServer +} from "http"; +import express from "express"; import Stream from "./stream"; import Station from "./station"; -import Server from "./server"; import Storage from "./storage"; +import PluginManager from "./plugins"; import Playlist from "./playlist"; import Item from "./item"; +import clientMiddleware from "./client/client-middleware"; +import allowMiddleware from "./client/client-allow-middleware"; +import pkg from "../package"; + +class JsCast extends EventEmitter { + constructor(options) { + super(); + + options = options || {}; + + this.stationOptions = options.stationOptions || {}; + this.station = options.station || new Station(this.stationOptions); + + this.pluginManagerOptions = options.pluginManagerOptions || {}; + this.pluginManager = new PluginManager(this.pluginManagerOptions); + } + + start(options) { + options = options || {}; + + this.app = options.app || express(); + this.socket = options.socket || new HttpServer(this.app); + this.port = options.port || 8000; + this.allow = options.allow || function () { + return true; + }; + + this.station = options.station || this.station; + this.pluginManager = options.pluginManager || this.pluginManager; + + // TODO: universal (client) middlewares + this.app.use((req, res, next) => { + res.setHeader("x-powered-by", `jscast v${pkg.version} https://github.com/BigTeri/jscast`); + next(); + }); + this.app.use(clientMiddleware); + this.app.use(allowMiddleware(this.allow, (client) => { + this.emit("clientRejected", client); + })); + + return this.pluginManager + .activate(this) + .then((options) => { + return new Promise((resolve) => { + if (options.socket && options.port) { + // TODO: listen to socket + this.listen(options.socket, options.port, () => { + resolve(); + }); + } else { + resolve(); + } + }); + }) + .then(() => { + this.station.start(); // TODO: promises + + return this; + }); + } + + listen(socket, port, done) { + if (typeof port === "function") { + done = port; + port = null; + } + + port = this.port = port || this.port; + + this.once("start", () => { + done && done(); + }); + + socket.listen(port, () => { + this.emit("start"); + }); + + return socket; + } +} + +function jscast(options) { + return new JsCast(options); +} export { + jscast, + JsCast, Stream, Station, - Server, Storage, + PluginManager, Playlist, Item }; -export default Server; +export default jscast; diff --git a/src/icy-server/index.js b/src/plugins/icy-server/index.js similarity index 89% rename from src/icy-server/index.js rename to src/plugins/icy-server/index.js index 05c5fe0..589b48f 100644 --- a/src/icy-server/index.js +++ b/src/plugins/icy-server/index.js @@ -5,14 +5,13 @@ import { Server as HttpServer } from "http"; import express from "express"; -import Station from "../station"; -import Client from "../client"; +import Station from "../../station"; +import Client from "../../client"; export default class IcyServer extends EventEmitter { - constructor(options) { - super(); - + activate(options) { options = options || {}; + this.name = options.name || "jscast - A SHOUTcast Server/Library written in JavaScript"; this.url = options.url || "https://github.com/BigTeri/jscast"; this.genre = options.genre || "Music"; @@ -26,20 +25,19 @@ export default class IcyServer extends EventEmitter { this.station = options.station || new Station(this.stationOptions); this.app = options.app || express(); this.socket = options.socket || new HttpServer(this.app); + this.port = options.port || 8000; this.station.on("data", (data, metadata) => { if (data) { let metadataBuffer = data; + if (!this.skipMetadata) { metadataBuffer = metadata.createCombinedBuffer(data); } + this.clients.forEach((client) => { const sendMetadata = !this.skipMetadata && client.wantsMetadata; - if (sendMetadata) { - client.write(metadataBuffer); - } else { - client.write(data); - } + client.write(sendMetadata ? metadataBuffer : data); }); } }); diff --git a/src/plugins/index.js b/src/plugins/index.js new file mode 100644 index 0000000..f74c79d --- /dev/null +++ b/src/plugins/index.js @@ -0,0 +1,71 @@ +import ManageType from "./manage"; +import IcyServerType from "./icy-server"; +import SpeakerType from "./speaker"; + +const typePluginMap = {}; + +export default class PluginManager { + constructor(options) { + options = options || {}; + + this.types = options.types || ["Manage", "IcyServer", "Speaker"]; + this.typePlugins = this.types.map((type) => PluginManager.getType(type)); + } + + activate(options) { + options = options || {}; + + const promises = this.typePlugins.map((typePlugin) => { + const pluginOptions = options[typePlugin.typeName] || {}; + pluginOptions.app = pluginOptions.app || options.app; + pluginOptions.socket = pluginOptions.socket || options.socket; + pluginOptions.port = pluginOptions.port || options.port; + pluginOptions.station = pluginOptions.station || options.station; + + return Promise + .resolve(typePlugin.activate(pluginOptions)) + .then(() => { + if (typePlugin.app) { + pluginOptions.app = pluginOptions.app || typePlugin.app; + options.app = pluginOptions.app; + } + + if (typePlugin.socket) { + pluginOptions.socket = pluginOptions.socket || typePlugin.socket; + options.socket = pluginOptions.socket; + } + }); + }); + + return Promise + .all(promises) + .then(() => { + return options; + }); + } + + isActive(type) { + return this.types.indexOf(type) > -1; + } + + getActiveType(type) { + return this.isActive(type) && PluginManager.getType(type); + } + + static registerType(type, typePlugin) { + typePlugin.typeName = type; + typePluginMap[type] = typePlugin; + } + + static getType(type) { + return typePluginMap[type]; + } + + static getTypeNames() { + return Object.keys(typePluginMap); + } +} + +PluginManager.registerType("Manage", new ManageType()); +PluginManager.registerType("IcyServer", new IcyServerType()); +PluginManager.registerType("Speaker", new SpeakerType()); diff --git a/src/manage/index.js b/src/plugins/manage/index.js similarity index 74% rename from src/manage/index.js rename to src/plugins/manage/index.js index db8f320..1a5441f 100644 --- a/src/manage/index.js +++ b/src/plugins/manage/index.js @@ -7,20 +7,19 @@ import { import path from "path"; import express from "express"; import SocketIOServer from "socket.io"; -import Station from "../station"; +import Station from "../../station"; export default class Manage extends EventEmitter { - constructor(options) { - super(); - + activate(options) { options = options || {}; this.app = options.app || express(); this.socket = options.socket || new HttpServer(this.app); + this.port = options.port || 8000; this.rootPath = options.rootPath || "/manage"; this.playerSourcePath = options.playerSourcePath || "/"; - this.staticFolderPath = options.staticFolderPath || path.join(__dirname, "../../", "./manage"); - this.jspmPath = options.jspmPath || path.join(__dirname, "../../"); + this.staticFolderPath = options.staticFolderPath || path.join(__dirname, "../../../", "./manage"); + this.jspmPath = options.jspmPath || path.join(__dirname, "../../../"); this.jspmPackagesPath = this.jspmPackagesPath || path.join(this.jspmPath, "./jspm_packages"); this.jspmConfigPath = this.jspmConfigPath || path.join(this.jspmPath, "./config.js"); this.stationOptions = options.stationOptions || {}; @@ -36,19 +35,19 @@ export default class Manage extends EventEmitter { this.app.use("/jspm_packages", this.jspmRouter); this.app.get("/config.js", (req, res) => res.sendFile(fixWindowsPath(this.jspmConfigPath))); - // TODO: allow for socket.io this.webSocketClients = []; + // TODO: allow for socket.io this.io = SocketIOServer(this.socket, { path: fixWindowsPath(path.join("/", this.rootPath, "/sockets")) - }).on("connection", (socket) => { - this.webSocketClients.push(socket); - this.emit("webSocketClientConnect", socket); + }).on("connection", (clientSocket) => { + this.webSocketClients.push(clientSocket); + this.emit("webSocketClientConnect", clientSocket); - socket.once("disconnect", () => { - this.webSocketClients.splice(this.webSocketClients.indexOf(socket), 1); - this.emit("webSocketClientDisconnect", socket); + clientSocket.once("disconnect", () => { + this.webSocketClients.splice(this.webSocketClients.indexOf(clientSocket), 1); + this.emit("webSocketClientDisconnect", clientSocket); }).on("fetch", () => { - socket.emit("info", { + clientSocket.emit("info", { item: this.station.item, metadata: this.station.metadata, playlists: this.station.playlists, @@ -73,24 +72,24 @@ export default class Manage extends EventEmitter { }); this.station.on("play", (item, metadata) => { - this.webSocketClients.forEach((socket) => { - socket.emit("playing", item, metadata); + this.webSocketClients.forEach((clientSocket) => { + clientSocket.emit("playing", item, metadata); }); }).on("playlistCreated", (playlist) => { - this.webSocketClients.forEach((socket) => { - socket.emit("playlistCreated", playlist); + this.webSocketClients.forEach((clientSocket) => { + clientSocket.emit("playlistCreated", playlist); }); }).on("itemCreated", (item, playlist) => { - this.webSocketClients.forEach((socket) => { - socket.emit("itemCreated", item, playlist._id); + this.webSocketClients.forEach((clientSocket) => { + clientSocket.emit("itemCreated", item, playlist._id); }); }).on("itemRemoved", (item, playlist) => { - this.webSocketClients.forEach((socket) => { - socket.emit("itemRemoved", item._id, playlist._id); + this.webSocketClients.forEach((clientSocket) => { + clientSocket.emit("itemRemoved", item._id, playlist._id); }); }).on("playlistRemoved", (playlist) => { - this.webSocketClients.forEach((socket) => { - socket.emit("playlistRemoved", playlist._id); + this.webSocketClients.forEach((clientSocket) => { + clientSocket.emit("playlistRemoved", playlist._id); }); }); } diff --git a/src/plugins/speaker/index.js b/src/plugins/speaker/index.js new file mode 100644 index 0000000..48e0a50 --- /dev/null +++ b/src/plugins/speaker/index.js @@ -0,0 +1,23 @@ +import lame from "lame"; +import Speaker from "speaker"; +import Station from "../../station"; + +export default class SpeakerType { + activate(options) { + options = options || {}; + + this.stationOptions = options.stationOptions || {}; + this.station = options.station || new Station(this.stationOptions); + + this.decoder = options.decoder || new lame.Decoder(); + this.speaker = options.speaker || new Speaker(); + + this.station.on("data", (data) => { + if (data && data.length) { + this.decoder.write(data); + } + }); + + this.decoder.pipe(this.speaker); + } +} diff --git a/src/server/index.js b/src/server/index.js deleted file mode 100644 index 3cb644e..0000000 --- a/src/server/index.js +++ /dev/null @@ -1,80 +0,0 @@ -import { - EventEmitter -} from "events"; -import { - Server as HttpServer -} from "http"; -import express from "express"; -import Station from "../station"; -import IcyServer from "../icy-server"; -import Manage from "../manage"; -import clientMiddleware from "../client/client-middleware"; -import allowMiddleware from "../client/client-allow-middleware"; - -export default class Server extends EventEmitter { - constructor(options) { - super(); - - this.icyServerRootPath = options.icyServerRootPath || "/"; - this.manageRootPath = options.manageRootPath || "/manage"; - this.stationOptions = options.stationOptions || {}; - this.station = options.station || new Station(this.stationOptions); - this.app = options.app || express(); - this.socket = options.socket || new HttpServer(this.app); - this.port = options.port || 8000; - this.allow = options.allow || function () { - return true; - }; - - this.station.on("error", (err) => this.emit("error", err)); - this.station.on("play", (item, metadata) => this.emit("play", item, metadata)); - this.station.on("nothingToPlay", (playlist) => this.emit("nothingToPlay", playlist)); - - // TODO: universal (client) middlewares - this.app.use((req, res, next) => { - res.setHeader("x-powered-by", "jscast https://github.com/BigTeri/jscast"); - next(); - }); - this.app.use(clientMiddleware); - this.app.use(allowMiddleware(this.allow, (client) => { - this.emit("clientRejected", client); - })); - - this.icyServerOptions = options.icyServerOptions || {}; - this.icyServerOptions.rootPath = this.icyServerOptions.rootPath || this.icyServerRootPath; - this.icyServerOptions.socket = this.icyServerOptions.socket || this.socket; - this.icyServerOptions.app = this.icyServerOptions.app || this.app; - this.icyServerOptions.station = this.icyServerOptions.station || this.station; - this.icyServer = options.icyServer || new IcyServer(this.icyServerOptions); - this.icyServer.on("clientConnect", (client) => this.emit("icyServerClientConnect", client)); - this.icyServer.on("clientDisconnect", (client) => this.emit("icyServerClientDisconnect", client)); - - this.manageOptions = options.manageOptions || {}; - this.manageOptions.rootPath = this.manageOptions.rootPath || this.manageRootPath; - this.manageOptions.playerSourcePath = this.manageOptions.playerSourcePath || this.icyServerRootPath; - this.manageOptions.socket = this.manageOptions.socket || this.socket; - this.manageOptions.app = this.manageOptions.app || this.app; - this.manageOptions.station = this.manageOptions.station || this.station; - this.manage = options.manage || new Manage(this.manageOptions); - this.manage.on("webSocketClientConnect", (client) => this.emit("manageWebSocketClientConnect", client)); - this.manage.on("webSocketClientDisconnect", (client) => this.emit("manageWebSocketClientDisconnect", client)); - } - - listen(port, done) { - if (typeof port === "function") { - done = port; - port = null; - } - - port = this.port = port || this.port; - - this.once("start", () => { - this.station.start(); - done && done(this); - }); - - this.socket.listen(port, () => { - this.emit("start"); - }); - } -} diff --git a/src/station/index.js b/src/station/index.js index e31a269..1389acc 100644 --- a/src/station/index.js +++ b/src/station/index.js @@ -6,7 +6,6 @@ import Stream from "../stream"; import Storage from "../storage"; import Playlist from "../playlist"; import Metadata from "./metadata"; -import VirtualPlayer from "./virtual-player"; import destroy from "destroy"; export default class Station extends EventEmitter { @@ -24,7 +23,6 @@ export default class Station extends EventEmitter { this.ffmpegPath && ffmpeg.setFfmpegPath(this.ffmpegPath); this.storage = new Storage(this.storageType); - this.virtualPlayer = new VirtualPlayer(); this.itemId = null; this.item = null; @@ -289,8 +287,11 @@ export default class Station extends EventEmitter { options = options || {}; if (options.streamNeedsPostProcessing) { - stream = ffmpeg(stream).audioBitrate(this.postProcessingBitRate).format("mp3"); + stream = ffmpeg(stream) + .audioBitrate(this.postProcessingBitRate) + .format("mp3"); } + return this.handleStreamError(stream); } } diff --git a/src/station/virtual-player.js b/src/station/virtual-player.js deleted file mode 100644 index 7fea0e3..0000000 --- a/src/station/virtual-player.js +++ /dev/null @@ -1,5 +0,0 @@ -export default class VirtualPlayer { - constructor() { - - } -} diff --git a/src/stream/index.js b/src/stream/index.js index 07c52bf..e167459 100644 --- a/src/stream/index.js +++ b/src/stream/index.js @@ -2,7 +2,6 @@ import { EventEmitter } from "events"; import StreamInfo from "./info"; -import BigBuffer from "../big-buffer"; export default class Stream extends EventEmitter { constructor(options) { @@ -14,7 +13,6 @@ export default class Stream extends EventEmitter { this.dataInterval = options.dataInterval || 500; this.needMoreData = options.needMoreData || function () {}; this.streamInfos = []; - this.bigBuffer = new BigBuffer(); } start() { @@ -68,9 +66,11 @@ export default class Stream extends EventEmitter { } getRealtimeBufferSize() { - return this.streamInfos.map(streamInfo => streamInfo.buffer.length).reduce((previous, length) => { - return previous + length; - }, 0); + return this.streamInfos + .map(streamInfo => streamInfo.buffer.length) + .reduce((previous, length) => { + return previous + length; + }, 0); } next(stream, metadata, item) {