├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── _config.yml ├── bin └── index.js ├── client ├── css │ ├── global.css │ ├── index.css │ ├── layout.css │ └── normalize.css ├── index.html └── js │ ├── bindings │ └── player.js │ ├── connection.js │ ├── index.js │ ├── local-settings.js │ └── models │ ├── item.js │ └── playlist.js ├── demo └── index.js ├── dist ├── client │ ├── css │ │ ├── global.css │ │ ├── index.css │ │ ├── layout.css │ │ └── normalize.css │ ├── index.html │ └── index.js └── server │ ├── cli.js │ ├── client │ ├── client-allow-middleware.js │ ├── client-middleware.js │ └── index.js │ ├── index.js │ ├── item │ ├── index.js │ └── types │ │ ├── file.js │ │ ├── stream.js │ │ └── youtube.js │ ├── package.js │ ├── playlist │ └── index.js │ ├── plugins │ ├── icy-server │ │ └── index.js │ ├── index.js │ ├── speaker │ │ └── index.js │ └── web-client │ │ └── index.js │ ├── station │ ├── index.js │ └── metadata.js │ ├── storage │ ├── index.js │ └── types │ │ ├── json.js │ │ └── memory.js │ └── stream │ ├── index.js │ └── info.js ├── docs └── images │ └── jsCast-Web.png ├── install-rpi-ffmpeg.sh ├── jspm.config.js ├── package.json └── src ├── cli.js ├── client ├── client-allow-middleware.js ├── client-middleware.js └── index.js ├── index.js ├── item ├── index.js └── types │ ├── file.js │ ├── stream.js │ └── youtube.js ├── package.js ├── playlist └── index.js ├── plugins ├── icy-server │ └── index.js ├── index.js ├── speaker │ └── index.js └── web-client │ └── index.js ├── station ├── index.js └── metadata.js ├── storage ├── index.js └── types │ ├── json.js │ └── memory.js └── stream ├── index.js └── info.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["node6"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | /json 5 | jspm_packages 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ardean 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsCast 2 | 3 | [![NPM Version][npm-image]][downloads-url] [![NPM Downloads][downloads-image]][downloads-url] 4 | 5 | **An Audio Streaming Application written in JavaScript** 6 | 7 | - _storage types / API_ 8 | - _input (item) types / API_ 9 | - _plugin types / API_ 10 | - _CLI support_ 11 | - _whitelist / blacklist_ 12 | 13 | ![jsCast - Web](docs/images/jsCast-Web.png) 14 | 15 | ## Quick Start 16 | 17 | ### Using CLI 18 | 19 | Install jsCast globally: 20 | 21 | ```sh 22 | $ npm i -g jscast 23 | ``` 24 | 25 | Use the new command to start an instance: 26 | 27 | ```sh 28 | $ jsCast 29 | ``` 30 | 31 | - override default port: `-p PORT` / `--port PORT` 32 | - change storage type: `-s TYPE` / `--storage-type TYPE` 33 | - set active plugins: `-t TYPE1,TYPE2` / `--plugin-types TYPE1,TYPE2` 34 | - ffmpeg binary path: `--ffmpeg-path PATH` 35 | - initial youtube items - fillable storage types **only**: `--youtube-items URL1,URL2` 36 | - whitelist: `--whitelist COUNTRY1,COUNTRY2` 37 | - blacklist: `--blacklist COUNTRY3,COUNTRY4` 38 | 39 | ### Using Script 40 | 41 | ```javascript 42 | import jsCast from "jscast"; 43 | import { log } from "util"; 44 | 45 | const instance = jsCast().on("clientRejected", (client) => { 46 | log(`client ${client.ip} rejected`); 47 | }); 48 | 49 | const icyServer = instance.pluginManager.getActiveType("IcyServer"); 50 | const webClient = instance.pluginManager.getActiveType("WebClient"); 51 | 52 | instance.station 53 | .on("play", (item, metadata) => { 54 | log(`playing ${metadata.options.StreamTitle}`); 55 | }) 56 | .on("nothingToPlay", (playlist) => { 57 | if (!playlist) { 58 | log("no playlist"); 59 | } else { 60 | log("playlist is empty"); 61 | } 62 | }); 63 | 64 | instance 65 | .start({ 66 | port: 8000, 67 | allow: (client) => { 68 | return true; // allow this client 69 | } 70 | }) 71 | .then(() => { 72 | log(`jscast is running`); 73 | 74 | if (icyServer) { 75 | icyServer 76 | .on("clientConnect", (client) => { 77 | log(`icy client ${client.ip} connected`); 78 | }) 79 | .on("clientDisconnect", (client) => { 80 | log(`icy client ${client.ip} disconnected`); 81 | }); 82 | 83 | log(`listen on http://localhost:${icyServer.port}${icyServer.rootPath}`); 84 | } 85 | 86 | if (webClient) { 87 | log(`Web Client on http://localhost:${webClient.port}${webClient.rootPath} your playlists and items`); 88 | } 89 | }) 90 | .catch((err) => console.error(err)); 91 | ``` 92 | 93 | ## Prerequisites 94 | 95 | first of all install [NodeJS](https://nodejs.org/), jscast is based on it. 96 | 97 | jscast uses [fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg#prerequisites) as dependency so ffmpeg **needs** to be installed on your system. 98 | 99 | ## Installation 100 | 101 | As dependency: 102 | 103 | ```sh 104 | $ npm install jscast 105 | ``` 106 | 107 | Play around and contribute to the project: 108 | 109 | ```sh 110 | $ git clone https://github.com/ardean/jsCast 111 | $ cd jsCast 112 | $ npm i 113 | $ npm start 114 | ``` 115 | 116 | ## Plugin Types 117 | 118 | ### Web Client 119 | 120 | **Web Client** is a `webapp` to control jsCast playlists and items. the route is `/web` by default. At the moment there is just a `YouTube` type implemented but the idea is to `control` everything with this `webapp`. 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... 121 | 122 | ### IcyServer 123 | 124 | The **IcyServer**'s task is to send the `SHOUTcast data` (received from the Station) to the `clients`. the route is `/` by default. 125 | 126 | ### Speaker 127 | 128 | This Plugin outputs the current track to the speakers. 129 | 130 | ## Station 131 | 132 | The **Station** is the core class which controls the `Stream` with his `data` and whatever currently is playing. 133 | 134 | ## Item Types 135 | 136 | Built-in item types: 137 | 138 | - **File** gets audio files from the filesystem using the `filename` option 139 | - **YouTube** fetches the audio data and info from YouTube using an `url` option 140 | - Use **Stream** to hand over a Readable Stream object with the `stream` option 141 | 142 | [more](#custom-items) item types 143 | 144 | ## Storage Types 145 | 146 | Built-in storage types: 147 | 148 | - **JSON** creates a folder with a json file per playlist, filename is the `playlist id` 149 | - **Memory** stores playlists in memory, so `changes will be lost` on shutdown 150 | 151 | If thats not enough, you can create [your own one](#custom-storages) 152 | 153 | ## Examples 154 | 155 | ### Custom Items 156 | 157 | jsCast has playlists with [typed items](#item-types). You can easily add your own item type: 158 | 159 | ```javascript 160 | import fs from "fs"; 161 | import { default as jsCast, Item } from "jscast"; 162 | import { log } from "util"; 163 | 164 | class MyItemType { 165 | constructor() { 166 | this.streamNeedsPostProcessing = true; // indicates if stream should be post processed to mp3 167 | } 168 | 169 | getStream(item, done) { 170 | // get stream code... 171 | log(item.type); // MyItem 172 | done && done(err, stream); 173 | } 174 | 175 | getMetadata(item, done) { 176 | // get metadata code... 177 | log(item.options.myProp); // myValue 178 | done && done(err, { 179 | StreamTitle: "my title" 180 | }); 181 | } 182 | } 183 | 184 | Item.registerType("MyItem", new MyItemType()); 185 | 186 | jsCast({ 187 | stationOptions: { 188 | storageType: "Memory", 189 | playlists: [{ 190 | type: "MyItem", 191 | options: { 192 | myProp: "myValue" 193 | } 194 | }, { 195 | type: "YouTube", 196 | options: { 197 | url: "https://www.youtube.com/watch?v=hhHXAMpnUPM" 198 | } 199 | }, { 200 | type: "Stream", 201 | options: { 202 | title: "A cool audio stream!", 203 | stream: fs.creadReadStream("./sound.mp3") 204 | } 205 | }, { 206 | type: "File", 207 | options: { 208 | title: "NICE TRACK!", 209 | filename: "./myTrack.mp3" 210 | } 211 | }] 212 | } 213 | }) 214 | .start() 215 | .catch((err) => console.error(err)); 216 | ``` 217 | 218 | ### Custom Storages 219 | 220 | You can use the built-in [storage types](#storage-types) or create your own one: 221 | 222 | ```javascript 223 | import { default as jsCast, Storage } from "jscast"; 224 | 225 | class MyStorageType { 226 | constructor() { 227 | this.isFillable = true; // indicates that this type can be pre filled on init 228 | } 229 | 230 | activate(options, done) { 231 | // initialize code... 232 | done && done(err); 233 | } 234 | 235 | fill(playlists, done) { 236 | // fill storage from playlists option in Server and Station class 237 | done && done(err); 238 | } 239 | 240 | findAll(done) { 241 | // findAll code... 242 | done && done(err, playlists); 243 | } 244 | 245 | insert(playlist, done) { 246 | // insert code... 247 | done && done(err); 248 | } 249 | 250 | update(playlist, done) { 251 | // update code... 252 | done && done(err); 253 | } 254 | 255 | remove(playlistId, done) { 256 | // remove code... 257 | done && done(err); 258 | } 259 | } 260 | 261 | Storage.registerType("MyStorage", new MyStorageType()); 262 | 263 | jsCast({ 264 | stationOptions: { 265 | storageType: "MyStorage" 266 | } 267 | }) 268 | .start() 269 | .catch((err) => console.error(err)); 270 | ``` 271 | 272 | ## TODO 273 | 274 | - API Documentation 275 | - Authentication 276 | - Change async to Promise 277 | 278 | ## License 279 | 280 | [MIT](LICENSE) 281 | 282 | [downloads-image]: https://img.shields.io/npm/dm/jscast.svg 283 | [downloads-url]: https://npmjs.org/package/jscast 284 | [npm-image]: https://img.shields.io/npm/v/jscast.svg 285 | [npm-url]: https://npmjs.org/package/jscast 286 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | require("../dist/server/cli"); 6 | -------------------------------------------------------------------------------- /client/css/global.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: "Open Sans", sans-serif; 3 | letter-spacing: 1.5px; 4 | } 5 | .disable-selection { 6 | user-select: none; 7 | -webkit-user-select: none; 8 | } 9 | -------------------------------------------------------------------------------- /client/css/index.css: -------------------------------------------------------------------------------- 1 | .head { 2 | padding-left: 20px; 3 | background-color: #FF5722; 4 | color: #FFF; 5 | flex: 0 0 auto; 6 | } 7 | 8 | .toolbar { 9 | padding: 10px; 10 | } 11 | 12 | .toolbar input { 13 | color: #FF5722; 14 | border: 0; 15 | padding: 5px 10px; 16 | margin: 3px 8px; 17 | } 18 | 19 | .toolbar-icon { 20 | padding: 5px; 21 | line-height: 0; 22 | cursor: pointer; 23 | } 24 | 25 | .icon-button { 26 | cursor: pointer; 27 | display: flex; 28 | align-items: center; 29 | } 30 | 31 | .playlist-list { 32 | padding: 0 20px; 33 | } 34 | 35 | .playlist-item { 36 | padding: 10px 0; 37 | margin: 10px 0; 38 | width: 100%; 39 | max-width: 600px; 40 | } 41 | 42 | .playlist-head { 43 | color: #FF5722; 44 | } 45 | 46 | .playlist-title { 47 | cursor: pointer; 48 | } 49 | 50 | .item-item { 51 | margin: 15px 0; 52 | } 53 | 54 | .item-inner { 55 | cursor: pointer; 56 | } 57 | 58 | .item-item .item-type { 59 | margin: 0; 60 | } 61 | 62 | .item-item.is-playing .item-type { 63 | color: #FF5722; 64 | } 65 | 66 | .item-hearing { 67 | display: none; 68 | } 69 | 70 | .item-item.is-playing .item-hearing { 71 | display: block; 72 | color: #FF5722; 73 | } 74 | 75 | .metadata { 76 | background-color: #FF5722; 77 | color: #FFF; 78 | padding: 10px 20px; 79 | flex: 0 0 auto; 80 | } 81 | -------------------------------------------------------------------------------- /client/css/layout.css: -------------------------------------------------------------------------------- 1 | .flex { 2 | flex: 1 1 auto; 3 | } 4 | .layout-horizontal { 5 | flex-direction: row; 6 | } 7 | .layout-vertical { 8 | flex-direction: column; 9 | } 10 | .layout-center { 11 | align-items: center; 12 | } 13 | .layout-center-center { 14 | justify-content: center; 15 | } 16 | .layout-horizontal, .layout-vertical { 17 | display: flex; 18 | } 19 | .flex-end { 20 | justify-content: flex-end; 21 | } 22 | .flex-space-around { 23 | justify-content: space-around; 24 | } 25 | .layout-wrap { 26 | flex-wrap: wrap; 27 | } 28 | -------------------------------------------------------------------------------- /client/css/normalize.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | height: 100%; 8 | } 9 | 10 | input[type="url"] { 11 | outline: 0; 12 | border: 1px solid #000; 13 | } 14 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | jsCast - Web 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |

jsCast - Web

25 |
26 |
27 | playlist_add 28 |
29 |
30 | skip_next 31 |
32 | 33 |
34 | add 35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 |
44 | play_arrow 45 |
46 |

47 |
48 | delete 49 |
50 |
51 |
52 |
53 |
54 |

55 | 56 | 57 |
58 | 59 | 60 |
61 | hearing 62 |
63 | delete 64 |
65 |
66 |
67 |
68 |
69 |
70 | 71 | 72 |
73 |
74 |
75 | 76 |
77 | 78 |
79 | 80 |
81 |
82 | 83 | 90 | 91 | 92 | 93 | 94 | 95 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /client/js/bindings/player.js: -------------------------------------------------------------------------------- 1 | import ko from "knockout"; 2 | 3 | ko.bindingHandlers.player = { 4 | init: function (element, valueAccessor) { 5 | var options = valueAccessor(); 6 | options.sourcePath.subscribe(onSourcePathChange); 7 | options.isPlaying.subscribe(onIsPlayingChange); 8 | options.isMuted.subscribe(onIsMutedChange); 9 | options.volume.subscribe(onVolumeChange); 10 | 11 | function onSourcePathChange(src) { 12 | if (src) { 13 | element.src = src; 14 | element.play(); 15 | } 16 | } 17 | 18 | function onIsMutedChange(isMuted) { 19 | element.muted = isMuted; 20 | } 21 | 22 | function onIsPlayingChange(isPlaying) { 23 | if (options.sourcePath()) { 24 | if (isPlaying) { 25 | element.play(); 26 | } else { 27 | element.pause(); 28 | } 29 | } 30 | } 31 | 32 | function onVolumeChange(volume) { 33 | element.volume = volume; 34 | } 35 | 36 | element.addEventListener("error", function () { 37 | setTimeout(function () { 38 | element.load(); 39 | element.play(); 40 | }, 3000); 41 | }); 42 | 43 | element.addEventListener("canplay", function () { 44 | options.canPlay(true); 45 | }); 46 | 47 | onSourcePathChange(options.sourcePath()); 48 | onIsPlayingChange(options.isPlaying()); 49 | onIsMutedChange(options.isMuted()); 50 | onVolumeChange(options.volume()); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /client/js/connection.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | const io = window.io; 3 | 4 | class Connection extends EventEmitter { 5 | constructor() { 6 | super(); 7 | 8 | this.socket = io(location.host, { 9 | path: location.pathname + "sockets" 10 | }); 11 | 12 | this.socket 13 | .on("connect", () => { 14 | this.isConnected = true; 15 | this.emit("connect"); 16 | }) 17 | .on("disconnect", () => { 18 | this.isConnected = false; 19 | this.emit("disconnect"); 20 | }); 21 | } 22 | } 23 | 24 | export default new Connection(); 25 | -------------------------------------------------------------------------------- /client/js/index.js: -------------------------------------------------------------------------------- 1 | import "./bindings/player.js"; 2 | import $ from "jquery"; 3 | import ko from "knockout"; 4 | import connection from "./connection.js"; 5 | import localSettings from "./local-settings.js"; 6 | import Playlist from "./models/playlist.js"; 7 | 8 | var item = ko.observable(); 9 | var metadata = ko.observable(); 10 | var playlists = ko.observableArray(); 11 | var itemUrl = ko.observable(); 12 | var playerSourcePath = ko.observable(); 13 | var browserCanPlay = ko.observable(false); 14 | var isPlayerPlaying = ko.observable(localSettings.isPlayerPlaying); 15 | var isPlayerMuted = ko.observable(localSettings.isPlayerMuted); 16 | var playerVolume = ko.observable(localSettings.playerVolume); 17 | 18 | const socket = connection.socket; 19 | 20 | connection.on("connect", () => { 21 | socket.emit("fetch"); 22 | }); 23 | 24 | playerVolume.subscribe((volume) => { 25 | localSettings.save("playerVolume", volume); 26 | }); 27 | 28 | isPlayerMuted.subscribe((isPlayerMuted) => { 29 | localSettings.save("isPlayerMuted", isPlayerMuted); 30 | }); 31 | 32 | socket.on("info", function (info) { 33 | info.playlists = info.playlists.map(function (playlist) { 34 | return new Playlist(playlist); 35 | }); 36 | 37 | Playlist.entities = info.playlists.concat(); 38 | 39 | playerSourcePath(info.playerSourcePath); 40 | item(info.item); 41 | metadata(info.metadata); 42 | playlists(info.playlists.concat()); 43 | }); 44 | 45 | socket.on("playing", function (itemObj, metadataObj) { 46 | item(itemObj); 47 | metadata(metadataObj); 48 | }); 49 | 50 | socket.on("playlistCreated", function (playlist) { 51 | playlist = new Playlist(playlist); 52 | 53 | Playlist.entities.push(playlist); 54 | playlists.push(playlist); 55 | }); 56 | 57 | socket.on("itemCreated", function (item, playlistId) { 58 | var playlist = Playlist.findById(playlistId); 59 | 60 | modifyPlaylist(playlist, function () { 61 | playlist.items.push(item); 62 | }); 63 | }); 64 | 65 | socket.on("itemRemoved", function (id, playlistId) { 66 | var playlist = Playlist.findById(playlistId); 67 | var item = playlist.findItemById(id); 68 | 69 | modifyPlaylist(playlist, function () { 70 | var itemIndex = playlist.items.indexOf(item); 71 | playlist.items.splice(itemIndex, 1); 72 | }); 73 | }); 74 | 75 | socket.on("playlistRemoved", function (playlistId) { 76 | var playlist = Playlist.findById(playlistId); 77 | 78 | Playlist.entities.splice(Playlist.entities.indexOf(playlist), 1); 79 | playlists.remove(playlist); 80 | }); 81 | 82 | function modifyPlaylist(playlist, fn) { 83 | var index = playlists.indexOf(playlist); 84 | playlists.remove(playlist); 85 | fn(); 86 | playlists.splice(index, 0, playlist); 87 | } 88 | 89 | function next() { 90 | socket.emit("next"); 91 | } 92 | 93 | function addPlaylist() { 94 | socket.emit("addPlaylist"); 95 | } 96 | 97 | function addItem() { 98 | var url = itemUrl(); 99 | if (!url) return; 100 | 101 | var item = { 102 | type: "YouTube", 103 | options: { 104 | url: url 105 | } 106 | }; 107 | 108 | itemUrl(""); 109 | 110 | socket.emit("addItem", item); 111 | } 112 | 113 | function removeItem(item, playlist) { 114 | var shouldRemove = confirm("Remove Item?"); 115 | if (shouldRemove) { 116 | socket.emit("removeItem", item._id, playlist._id); 117 | } 118 | } 119 | 120 | function removePlaylist(playlist) { 121 | var shouldRemove = confirm("Remove Playlist?"); 122 | if (shouldRemove) { 123 | socket.emit("removePlaylist", playlist._id); 124 | } 125 | } 126 | 127 | function playItem(item, playlist) { 128 | socket.emit("playItem", item._id, playlist._id); 129 | } 130 | 131 | function playPlaylist(playlist) { 132 | socket.emit("playPlaylist", playlist._id); 133 | } 134 | 135 | function isPlaying(itemId) { 136 | var itemValue = item(); 137 | return itemValue && itemId === itemValue._id; 138 | } 139 | 140 | ko.applyBindings({ 141 | playerSourcePath: playerSourcePath, 142 | isPlayerPlaying: isPlayerPlaying, 143 | isPlayerMuted: isPlayerMuted, 144 | playerVolume: playerVolume, 145 | item: item, 146 | metadata: metadata, 147 | playlists: playlists, 148 | itemUrl: itemUrl, 149 | next: next, 150 | addPlaylist: addPlaylist, 151 | addItem: addItem, 152 | removeItem: removeItem, 153 | removePlaylist: removePlaylist, 154 | playItem: playItem, 155 | playPlaylist: playPlaylist, 156 | browserCanPlay: browserCanPlay, 157 | isPlaying: isPlaying 158 | }); 159 | -------------------------------------------------------------------------------- /client/js/local-settings.js: -------------------------------------------------------------------------------- 1 | class LocalSettings { 2 | constructor() { 3 | const isPlayerPlaying = localStorage.getItem("isPlayerPlaying"); 4 | this.isPlayerPlaying = typeof isPlayerPlaying === "boolean" ? isPlayerPlaying : true; 5 | this.isPlayerMuted = localStorage.getItem("isPlayerMuted") || false; 6 | this.playerVolume = localStorage.getItem("playerVolume") || 0.75; 7 | } 8 | 9 | save(propertyName, value) { 10 | this[propertyName] = value; 11 | localStorage.setItem(propertyName, value); 12 | } 13 | } 14 | 15 | export default new LocalSettings(); 16 | -------------------------------------------------------------------------------- /client/js/models/item.js: -------------------------------------------------------------------------------- 1 | export default class Item { 2 | constructor(options) { 3 | this._id = options._id; 4 | this.type = options.type; 5 | this.options = options.options; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/js/models/playlist.js: -------------------------------------------------------------------------------- 1 | import Item from "./item.js"; 2 | 3 | export default class Playlist { 4 | constructor(options) { 5 | this._id = options._id; 6 | this.items = options.items.map((item) => new Item(item)); 7 | } 8 | 9 | findItemById(id) { 10 | return this.items.find((item) => item._id === id); 11 | } 12 | 13 | static findById(id) { 14 | return Playlist.entities.find((playlist) => playlist._id === id); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import { log } from "util"; 2 | import { default as jsCast, PluginManager } from "../src"; 3 | import geoip from "geoip-lite"; 4 | import ip from "ip"; 5 | 6 | const mapYouTubeList = function (url) { 7 | return { 8 | type: "YouTube", 9 | options: { 10 | url: url 11 | } 12 | }; 13 | }; 14 | 15 | const yogscastPlaylist = [ 16 | "https://www.youtube.com/watch?v=99dM8__wphY", 17 | "https://www.youtube.com/watch?v=gqELqRCnW6g", 18 | "https://www.youtube.com/watch?v=D67jM8nO7Ag", 19 | "https://www.youtube.com/watch?v=kzeeV_Dl9gw", 20 | "https://www.youtube.com/watch?v=PWZylTw6RGY", 21 | "https://www.youtube.com/watch?v=ytWz0qVvBZ0", 22 | "https://www.youtube.com/watch?v=qOVLUiha1B8" 23 | ].map(mapYouTubeList); 24 | 25 | const suicidePlaylist = [ 26 | "https://www.youtube.com/watch?v=7S8t_LfA3y0" 27 | ].map(mapYouTubeList); 28 | 29 | const jsCastOptions = { 30 | // webClientRootPath: "/", 31 | // icyServerRootPath: "/listen", 32 | stationOptions: { 33 | // ffmpegPath: "C:/projects/ffmpeg/bin/ffmpeg.exe", 34 | storageType: "Memory", 35 | playlists: [ 36 | yogscastPlaylist, 37 | suicidePlaylist 38 | ] 39 | } 40 | }; 41 | 42 | const instance = jsCast(jsCastOptions) 43 | .on("clientRejected", (client) => { 44 | log(`client ${client.ip} rejected`); 45 | }); 46 | 47 | const icyServer = instance.pluginManager.getActiveType("IcyServer"); 48 | const webClient = instance.pluginManager.getActiveType("WebClient"); 49 | 50 | instance 51 | .station 52 | .on("play", (item, metadata) => { 53 | log(`playing ${metadata.options.StreamTitle}`); 54 | }) 55 | .on("nothingToPlay", (playlist) => { 56 | if (!playlist) { 57 | log("no playlist"); 58 | } else { 59 | log("playlist is empty"); 60 | } 61 | }); 62 | 63 | instance 64 | .start({ port: 8000 }) 65 | .then(() => { 66 | log(`jsCast is running`); 67 | 68 | if (icyServer) { 69 | icyServer 70 | .on("clientConnect", (client) => { 71 | log(`icy client ${client.ip} connected`); 72 | }) 73 | .on("clientDisconnect", (client) => { 74 | log(`icy client ${client.ip} disconnected`); 75 | }); 76 | 77 | log(`listen on http://localhost:${icyServer.port}${icyServer.rootPath}`); 78 | } 79 | 80 | if (webClient) { 81 | log(`Web Client on http://localhost:${webClient.port}${webClient.rootPath}`); 82 | } 83 | }) 84 | .catch((err) => { 85 | console.error(err); 86 | }); 87 | -------------------------------------------------------------------------------- /dist/client/css/global.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: "Open Sans", sans-serif; 3 | letter-spacing: 1.5px; 4 | } 5 | .disable-selection { 6 | user-select: none; 7 | -webkit-user-select: none; 8 | } 9 | -------------------------------------------------------------------------------- /dist/client/css/index.css: -------------------------------------------------------------------------------- 1 | .head { 2 | padding-left: 20px; 3 | background-color: #FF5722; 4 | color: #FFF; 5 | flex: 0 0 auto; 6 | } 7 | 8 | .toolbar { 9 | padding: 10px; 10 | } 11 | 12 | .toolbar input { 13 | color: #FF5722; 14 | border: 0; 15 | padding: 5px 10px; 16 | margin: 3px 8px; 17 | } 18 | 19 | .toolbar-icon { 20 | padding: 5px; 21 | line-height: 0; 22 | cursor: pointer; 23 | } 24 | 25 | .icon-button { 26 | cursor: pointer; 27 | display: flex; 28 | align-items: center; 29 | } 30 | 31 | .playlist-list { 32 | padding: 0 20px; 33 | } 34 | 35 | .playlist-item { 36 | padding: 10px 0; 37 | margin: 10px 0; 38 | width: 100%; 39 | max-width: 600px; 40 | } 41 | 42 | .playlist-head { 43 | color: #FF5722; 44 | } 45 | 46 | .playlist-title { 47 | cursor: pointer; 48 | } 49 | 50 | .item-item { 51 | margin: 15px 0; 52 | } 53 | 54 | .item-inner { 55 | cursor: pointer; 56 | } 57 | 58 | .item-item .item-type { 59 | margin: 0; 60 | } 61 | 62 | .item-item.is-playing .item-type { 63 | color: #FF5722; 64 | } 65 | 66 | .item-hearing { 67 | display: none; 68 | } 69 | 70 | .item-item.is-playing .item-hearing { 71 | display: block; 72 | color: #FF5722; 73 | } 74 | 75 | .metadata { 76 | background-color: #FF5722; 77 | color: #FFF; 78 | padding: 10px 20px; 79 | flex: 0 0 auto; 80 | } 81 | -------------------------------------------------------------------------------- /dist/client/css/layout.css: -------------------------------------------------------------------------------- 1 | .flex { 2 | flex: 1 1 auto; 3 | } 4 | .layout-horizontal { 5 | flex-direction: row; 6 | } 7 | .layout-vertical { 8 | flex-direction: column; 9 | } 10 | .layout-center { 11 | align-items: center; 12 | } 13 | .layout-center-center { 14 | justify-content: center; 15 | } 16 | .layout-horizontal, .layout-vertical { 17 | display: flex; 18 | } 19 | .flex-end { 20 | justify-content: flex-end; 21 | } 22 | .flex-space-around { 23 | justify-content: space-around; 24 | } 25 | .layout-wrap { 26 | flex-wrap: wrap; 27 | } 28 | -------------------------------------------------------------------------------- /dist/client/css/normalize.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | height: 100%; 8 | } 9 | 10 | input[type="url"] { 11 | outline: 0; 12 | border: 1px solid #000; 13 | } 14 | -------------------------------------------------------------------------------- /dist/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | jsCast - Web 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |

jsCast - Web

25 |
26 |
27 | playlist_add 28 |
29 |
30 | skip_next 31 |
32 | 33 |
34 | add 35 |
36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 |
44 | play_arrow 45 |
46 |

47 |
48 | delete 49 |
50 |
51 |
52 |
53 |
54 |

55 | 56 | 57 |
58 | 59 | 60 |
61 | hearing 62 |
63 | delete 64 |
65 |
66 |
67 |
68 |
69 |
70 | 71 | 72 |
73 |
74 |
75 | 76 |
77 | 78 |
79 | 80 |
81 |
82 | 83 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /dist/server/cli.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ip = require("ip"); 4 | 5 | var _ip2 = _interopRequireDefault(_ip); 6 | 7 | var _ = require("./"); 8 | 9 | var _2 = _interopRequireDefault(_); 10 | 11 | var _util = require("util"); 12 | 13 | var _geoipLite = require("geoip-lite"); 14 | 15 | var _geoipLite2 = _interopRequireDefault(_geoipLite); 16 | 17 | var _storage = require("./storage"); 18 | 19 | var _storage2 = _interopRequireDefault(_storage); 20 | 21 | var _commander = require("commander"); 22 | 23 | var _commander2 = _interopRequireDefault(_commander); 24 | 25 | var _package = require("./package"); 26 | 27 | var _plugins = require("./plugins"); 28 | 29 | var _plugins2 = _interopRequireDefault(_plugins); 30 | 31 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 32 | 33 | const allStorageTypeNames = _storage2.default.getTypeNames(); 34 | const allPluginTypeNames = _plugins2.default.getTypeNames(); 35 | 36 | _commander2.default.version(_package.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); 37 | 38 | const whitelist = _commander2.default.whitelist; 39 | const blacklist = _commander2.default.blacklist; 40 | const playlists = []; 41 | const playlist = (_commander2.default.youtubeItems || []).map(item => mapYouTubeList(item)); 42 | 43 | if (playlist.length) { 44 | playlists.push(playlist); 45 | } 46 | 47 | const jsCastOptions = { 48 | stationOptions: { 49 | storageType: _commander2.default.storageType, 50 | ffmpegPath: _commander2.default.ffmpegPath, 51 | playlists: playlists 52 | }, 53 | pluginManagerOptions: { 54 | types: _commander2.default.pluginTypes 55 | } 56 | }; 57 | 58 | const instance = (0, _2.default)(jsCastOptions).on("clientRejected", client => { 59 | (0, _util.log)(`client ${ client.ip } rejected`); 60 | }); 61 | 62 | const icyServer = instance.pluginManager.getActiveType("IcyServer"); 63 | const webClient = instance.pluginManager.getActiveType("WebClient"); 64 | 65 | instance.station.on("play", (item, metadata) => { 66 | (0, _util.log)(`playing ${ metadata.options.StreamTitle }`); 67 | }).on("nothingToPlay", playlist => { 68 | if (!playlist) { 69 | (0, _util.log)("no playlist"); 70 | } else { 71 | (0, _util.log)("playlist is empty"); 72 | } 73 | }); 74 | 75 | instance.start({ 76 | port: _commander2.default.port, 77 | allow: client => { 78 | if (_ip2.default.isEqual(client.ip, "127.0.0.1") || client.ip === "::1") return true; 79 | if ((!whitelist || !whitelist.length) && (!blacklist || !blacklist.length)) return true; 80 | 81 | const geo = _geoipLite2.default.lookup(client.ip); 82 | return isInCountryList(geo, whitelist) && !isInCountryList(geo, blacklist); 83 | } 84 | }).then(() => { 85 | (0, _util.log)(`jsCast is running`); 86 | 87 | if (icyServer) { 88 | icyServer.on("clientConnect", client => { 89 | (0, _util.log)(`icy client ${ client.ip } connected`); 90 | }).on("clientDisconnect", client => { 91 | (0, _util.log)(`icy client ${ client.ip } disconnected`); 92 | }); 93 | 94 | (0, _util.log)(`listen on http://localhost:${ icyServer.port }${ icyServer.rootPath }`); 95 | } 96 | 97 | if (webClient) { 98 | (0, _util.log)(`Web Client on http://localhost:${ webClient.port }${ webClient.rootPath }`); 99 | } 100 | }).catch(err => { 101 | console.error(err); 102 | }); 103 | 104 | function mapYouTubeList(url) { 105 | return { 106 | type: "YouTube", 107 | options: { 108 | url: url 109 | } 110 | }; 111 | } 112 | 113 | function isInCountryList(geo, list) { 114 | return geo && list && list.length && list.some(country => country === geo.country); 115 | } 116 | 117 | function parseList(data) { 118 | return (data || "").split(","); 119 | } -------------------------------------------------------------------------------- /dist/server/client/client-allow-middleware.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | exports.default = function (allow, rejected) { 8 | return function (req, res, next) { 9 | const client = req.jscastClient; 10 | if (client) { 11 | if (!allow(client)) { 12 | // TODO: allow promise 13 | rejected(client); 14 | return res.sendStatus(404); 15 | } else { 16 | next(); 17 | } 18 | } else { 19 | throw new Error("no client object"); 20 | } 21 | }; 22 | }; -------------------------------------------------------------------------------- /dist/server/client/client-middleware.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | exports.default = function (req, res, next) { 8 | req.jscastClient = new _2.default(req, res); 9 | next(); 10 | }; 11 | 12 | var _ = require("./"); 13 | 14 | var _2 = _interopRequireDefault(_); 15 | 16 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } -------------------------------------------------------------------------------- /dist/server/client/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | class Client { 7 | constructor(req, res) { 8 | this.req = req; 9 | this.res = res; 10 | this.ip = req.ip; 11 | this.wantsMetadata = req.headers["icy-metadata"] === "1"; 12 | } 13 | 14 | write(data) { 15 | this.res.write(data); 16 | } 17 | } 18 | exports.default = Client; -------------------------------------------------------------------------------- /dist/server/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.Item = exports.Playlist = exports.PluginManager = exports.Storage = exports.Station = exports.Stream = exports.JsCast = exports.jsCast = undefined; 7 | 8 | var _events = require("events"); 9 | 10 | var _http = require("http"); 11 | 12 | var _express = require("express"); 13 | 14 | var _express2 = _interopRequireDefault(_express); 15 | 16 | var _stream = require("./stream"); 17 | 18 | var _stream2 = _interopRequireDefault(_stream); 19 | 20 | var _station = require("./station"); 21 | 22 | var _station2 = _interopRequireDefault(_station); 23 | 24 | var _storage = require("./storage"); 25 | 26 | var _storage2 = _interopRequireDefault(_storage); 27 | 28 | var _plugins = require("./plugins"); 29 | 30 | var _plugins2 = _interopRequireDefault(_plugins); 31 | 32 | var _playlist = require("./playlist"); 33 | 34 | var _playlist2 = _interopRequireDefault(_playlist); 35 | 36 | var _item = require("./item"); 37 | 38 | var _item2 = _interopRequireDefault(_item); 39 | 40 | var _clientMiddleware = require("./client/client-middleware"); 41 | 42 | var _clientMiddleware2 = _interopRequireDefault(_clientMiddleware); 43 | 44 | var _clientAllowMiddleware = require("./client/client-allow-middleware"); 45 | 46 | var _clientAllowMiddleware2 = _interopRequireDefault(_clientAllowMiddleware); 47 | 48 | var _package = require("./package"); 49 | 50 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 51 | 52 | class JsCast extends _events.EventEmitter { 53 | constructor(options) { 54 | super(); 55 | 56 | options = options || {}; 57 | 58 | this.stationOptions = options.stationOptions || {}; 59 | this.station = options.station || new _station2.default(this.stationOptions); 60 | 61 | this.pluginManagerOptions = options.pluginManagerOptions || {}; 62 | this.pluginManager = new _plugins2.default(this.pluginManagerOptions); 63 | } 64 | 65 | start(options) { 66 | options = options || {}; 67 | 68 | this.app = options.app || (0, _express2.default)(); 69 | this.socket = options.socket || new _http.Server(this.app); 70 | this.port = options.port || 8000; 71 | this.allow = options.allow || function () { 72 | return true; 73 | }; 74 | 75 | this.station = options.station || this.station; 76 | this.pluginManager = options.pluginManager || this.pluginManager; 77 | 78 | // TODO: universal (client) middlewares 79 | this.app.use((req, res, next) => { 80 | res.setHeader("x-powered-by", `jsCast v${ _package.version } https://github.com/ardean/jsCast`); 81 | next(); 82 | }); 83 | this.app.use(_clientMiddleware2.default); 84 | this.app.use((0, _clientAllowMiddleware2.default)(this.allow, client => { 85 | this.emit("clientRejected", client); 86 | })); 87 | 88 | return this.pluginManager.activate(this).then(options => { 89 | return new Promise(resolve => { 90 | if (options.socket && options.port) { 91 | // TODO: listen to socket 92 | this.listen(options.socket, options.port, () => { 93 | resolve(); 94 | }); 95 | } else { 96 | resolve(); 97 | } 98 | }); 99 | }).then(() => { 100 | this.station.start(); // TODO: promises 101 | 102 | return this; 103 | }); 104 | } 105 | 106 | listen(socket, port, done) { 107 | if (typeof port === "function") { 108 | done = port; 109 | port = null; 110 | } 111 | 112 | port = this.port = port || this.port; 113 | 114 | this.once("start", () => { 115 | done && done(); 116 | }); 117 | 118 | socket.listen(port, () => { 119 | this.emit("start"); 120 | }); 121 | 122 | return socket; 123 | } 124 | } 125 | 126 | function jsCast(options) { 127 | return new JsCast(options); 128 | } 129 | 130 | exports.jsCast = jsCast; 131 | exports.JsCast = JsCast; 132 | exports.Stream = _stream2.default; 133 | exports.Station = _station2.default; 134 | exports.Storage = _storage2.default; 135 | exports.PluginManager = _plugins2.default; 136 | exports.Playlist = _playlist2.default; 137 | exports.Item = _item2.default; 138 | exports.default = jsCast; -------------------------------------------------------------------------------- /dist/server/item/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _shortid = require("shortid"); 8 | 9 | var _shortid2 = _interopRequireDefault(_shortid); 10 | 11 | var _file = require("./types/file"); 12 | 13 | var _file2 = _interopRequireDefault(_file); 14 | 15 | var _stream = require("./types/stream"); 16 | 17 | var _stream2 = _interopRequireDefault(_stream); 18 | 19 | var _youtube = require("./types/youtube"); 20 | 21 | var _youtube2 = _interopRequireDefault(_youtube); 22 | 23 | var _destroy = require("destroy"); 24 | 25 | var _destroy2 = _interopRequireDefault(_destroy); 26 | 27 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 28 | 29 | const typePluginMap = {}; 30 | 31 | class Item { 32 | constructor(options) { 33 | this._id = _shortid2.default.generate(); 34 | this.type = options.type; 35 | this.options = options.options; 36 | this.typePlugin = Item.getType(this.type); 37 | } 38 | 39 | load(done) { 40 | this.typePlugin.getStream(this, (err, stream) => { 41 | if (err) return done(err); 42 | stream.once("error", () => {}); 43 | 44 | this.typePlugin.getMetadata(this, (err, metadata) => { 45 | if (err) { 46 | (0, _destroy2.default)(stream); 47 | return done(err); 48 | } 49 | 50 | done(null, stream, metadata, { 51 | streamNeedsPostProcessing: this.typePlugin.streamNeedsPostProcessing 52 | }); 53 | }); 54 | }); 55 | } 56 | 57 | toJSON() { 58 | return { 59 | _id: this._id, 60 | type: this.type, 61 | options: this.options 62 | }; 63 | } 64 | 65 | static registerType(type, typePlugin) { 66 | typePluginMap[type] = typePlugin; 67 | } 68 | 69 | static getType(type) { 70 | const typePlugin = typePluginMap[type]; 71 | if (!typePlugin) throw new Error("Unknown item type"); 72 | return typePlugin; 73 | } 74 | } 75 | 76 | exports.default = Item; 77 | Item.registerType("File", new _file2.default()); 78 | Item.registerType("Stream", new _stream2.default()); 79 | Item.registerType("YouTube", new _youtube2.default()); -------------------------------------------------------------------------------- /dist/server/item/types/file.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _fs = require("fs"); 8 | 9 | var _fs2 = _interopRequireDefault(_fs); 10 | 11 | var _path = require("path"); 12 | 13 | var _path2 = _interopRequireDefault(_path); 14 | 15 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 16 | 17 | class FileType { 18 | constructor() { 19 | this.streamNeedsPostProcessing = true; 20 | } 21 | 22 | getStream(item, done) { 23 | done(null, _fs2.default.createReadStream(item.options.filename)); 24 | } 25 | 26 | getMetadata(item, done) { 27 | done(null, { 28 | StreamTitle: item.options.title || _path2.default.basename(item.options.filename, _path2.default.extname(item.options.filename)) 29 | }); 30 | } 31 | } 32 | exports.default = FileType; -------------------------------------------------------------------------------- /dist/server/item/types/stream.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | class StreamType { 7 | constructor() { 8 | this.streamNeedsPostProcessing = true; 9 | } 10 | 11 | getStream(item, done) { 12 | done(null, item.options.stream); 13 | } 14 | 15 | getMetadata(item, done) { 16 | done(null, { 17 | StreamTitle: item.options.title || "" 18 | }); 19 | } 20 | } 21 | exports.default = StreamType; -------------------------------------------------------------------------------- /dist/server/item/types/youtube.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _ytdlCore = require("ytdl-core"); 8 | 9 | var _ytdlCore2 = _interopRequireDefault(_ytdlCore); 10 | 11 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 12 | 13 | class YouTubeType { 14 | constructor() { 15 | this.streamNeedsPostProcessing = true; 16 | } 17 | 18 | getStream(item, done) { 19 | done(null, (0, _ytdlCore2.default)(item.options.url)); 20 | } 21 | 22 | getMetadata(item, done) { 23 | _ytdlCore2.default.getInfo(item.options.url, (err, info) => { 24 | if (err) return done(err); 25 | done(null, { 26 | StreamTitle: info.author.name + " - " + info.title 27 | }); 28 | }); 29 | } 30 | } 31 | exports.default = YouTubeType; -------------------------------------------------------------------------------- /dist/server/package.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = { 7 | version: "0.5.0" 8 | }; -------------------------------------------------------------------------------- /dist/server/playlist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _events = require("events"); 8 | 9 | var _shortid = require("shortid"); 10 | 11 | var _shortid2 = _interopRequireDefault(_shortid); 12 | 13 | var _item = require("../item"); 14 | 15 | var _item2 = _interopRequireDefault(_item); 16 | 17 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 18 | 19 | class Playlist extends _events.EventEmitter { 20 | constructor(options) { 21 | super(); 22 | 23 | this._id = options._id || _shortid2.default.generate(); 24 | this.items = options.items || []; 25 | this.items = this.items.map(options => new _item2.default(options)); 26 | this.index = typeof options.index === "number" ? options.index : -1; 27 | } 28 | 29 | addItem(options) { 30 | const item = new _item2.default(options); 31 | this.items.push(item); 32 | return item; 33 | } 34 | 35 | removeItem(id) { 36 | const item = this.findItemById(id); 37 | if (item) { 38 | const index = this.items.indexOf(item); 39 | if (index > -1) { 40 | this.items.splice(index, 1); 41 | return item; 42 | } else { 43 | return false; 44 | } 45 | } else { 46 | return false; 47 | } 48 | } 49 | 50 | findItemById(id) { 51 | return this.items.find(item => item._id === id); 52 | } 53 | 54 | replaceItemByItemId(itemId) { 55 | const item = this.findItemById(itemId); 56 | return this.replaceItem(item); 57 | } 58 | 59 | replaceItem(item) { 60 | if (!item) return false; 61 | this.loadItem(item, "replace"); 62 | return true; 63 | } 64 | 65 | loadItem(item, eventName) { 66 | item.load((err, stream, metadata, options) => { 67 | this.emit(eventName, err, stream, metadata, item, options); 68 | }); 69 | } 70 | 71 | playNext() { 72 | return this.next("play"); 73 | } 74 | 75 | replaceNext() { 76 | return this.next("replace"); 77 | } 78 | 79 | next(eventName) { 80 | this.setNextIndex(); 81 | const item = this.items[this.index]; 82 | if (!item) return false; 83 | this.loadItem(item, eventName); 84 | return true; 85 | } 86 | 87 | setNextIndex() { 88 | if (this.index + 1 >= this.items.length) { 89 | this.index = 0; 90 | return this.index; 91 | } else { 92 | return ++this.index; 93 | } 94 | } 95 | 96 | toJSON() { 97 | return { 98 | _id: this._id, 99 | items: this.items 100 | }; 101 | } 102 | } 103 | exports.default = Playlist; -------------------------------------------------------------------------------- /dist/server/plugins/icy-server/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _events = require("events"); 8 | 9 | var _http = require("http"); 10 | 11 | var _express = require("express"); 12 | 13 | var _express2 = _interopRequireDefault(_express); 14 | 15 | var _station = require("../../station"); 16 | 17 | var _station2 = _interopRequireDefault(_station); 18 | 19 | var _client = require("../../client"); 20 | 21 | var _client2 = _interopRequireDefault(_client); 22 | 23 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 24 | 25 | class IcyServer extends _events.EventEmitter { 26 | activate(options) { 27 | options = options || {}; 28 | 29 | this.name = options.name || "jsCast - An Audio Streaming Application written in JavaScript"; 30 | this.url = options.url || "https://github.com/ardean/jsCast"; 31 | this.genre = options.genre || "Music"; 32 | this.isPublic = options.isPublic || false; 33 | this.bitrate = options.bitrate || 128; 34 | this.bufferSize = options.bufferSize || 8192; 35 | this.skipMetadata = options.skipMetadata || false; 36 | this.rootPath = options.rootPath || "/"; 37 | 38 | this.stationOptions = options.stationOptions || {}; 39 | this.station = options.station || new _station2.default(this.stationOptions); 40 | this.app = options.app || (0, _express2.default)(); 41 | this.socket = options.socket || new _http.Server(this.app); 42 | this.port = options.port || 8000; 43 | 44 | this.station.on("data", (data, metadata) => { 45 | if (data) { 46 | let metadataBuffer = data; 47 | 48 | if (!this.skipMetadata) { 49 | metadataBuffer = metadata.createCombinedBuffer(data); 50 | } 51 | 52 | this.clients.forEach(client => { 53 | const sendMetadata = !this.skipMetadata && client.wantsMetadata; 54 | client.write(sendMetadata ? metadataBuffer : data); 55 | }); 56 | } 57 | }); 58 | 59 | this.clients = []; 60 | this.app.get(this.rootPath, (req, res) => this.clientConnected(new _client2.default(req, res))); 61 | } 62 | 63 | clientConnected(client) { 64 | this.clients.push(client); 65 | this.emit("clientConnect", client); 66 | 67 | client.res.writeHead(200, this.getHeaders(client)); 68 | client.req.once("close", this.clientDisconnected.bind(this, client)); 69 | } 70 | 71 | clientDisconnected(client) { 72 | this.clients.splice(this.clients.indexOf(client), 1); 73 | this.emit("clientDisconnect", client); 74 | } 75 | 76 | getHeaders(client) { 77 | const sendMetadata = !this.skipMetadata && client.wantsMetadata; 78 | return { 79 | "Content-Type": "audio/mpeg", 80 | "icy-name": this.name, 81 | "icy-url": this.url, 82 | "icy-genre": this.genre, 83 | "icy-pub": this.isPublic ? "1" : "0", 84 | "icy-br": this.bitrate.toString(), 85 | "icy-metaint": sendMetadata ? this.bufferSize.toString() : "0" 86 | }; 87 | } 88 | } 89 | exports.default = IcyServer; -------------------------------------------------------------------------------- /dist/server/plugins/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _webClient = require("./web-client"); 8 | 9 | var _webClient2 = _interopRequireDefault(_webClient); 10 | 11 | var _icyServer = require("./icy-server"); 12 | 13 | var _icyServer2 = _interopRequireDefault(_icyServer); 14 | 15 | var _speaker = require("./speaker"); 16 | 17 | var _speaker2 = _interopRequireDefault(_speaker); 18 | 19 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 20 | 21 | const typePluginMap = {}; 22 | 23 | class PluginManager { 24 | constructor(options) { 25 | options = options || {}; 26 | 27 | this.types = options.types || ["WebClient", "IcyServer", "Speaker"]; 28 | this.typePlugins = this.types.map(type => PluginManager.getType(type)); 29 | } 30 | 31 | activate(options) { 32 | options = options || {}; 33 | 34 | const promises = this.typePlugins.map(typePlugin => { 35 | const pluginOptions = options[typePlugin.typeName] || {}; 36 | pluginOptions.app = pluginOptions.app || options.app; 37 | pluginOptions.socket = pluginOptions.socket || options.socket; 38 | pluginOptions.port = pluginOptions.port || options.port; 39 | pluginOptions.station = pluginOptions.station || options.station; 40 | 41 | return Promise.resolve(typePlugin.activate(pluginOptions)).then(() => { 42 | if (typePlugin.app) { 43 | pluginOptions.app = pluginOptions.app || typePlugin.app; 44 | options.app = pluginOptions.app; 45 | } 46 | 47 | if (typePlugin.socket) { 48 | pluginOptions.socket = pluginOptions.socket || typePlugin.socket; 49 | options.socket = pluginOptions.socket; 50 | } 51 | }); 52 | }); 53 | 54 | return Promise.all(promises).then(() => { 55 | return options; 56 | }); 57 | } 58 | 59 | isActive(type) { 60 | return this.types.indexOf(type) > -1; 61 | } 62 | 63 | getActiveType(type) { 64 | return this.isActive(type) && PluginManager.getType(type); 65 | } 66 | 67 | static registerType(type, typePlugin) { 68 | typePlugin.typeName = type; 69 | typePluginMap[type] = typePlugin; 70 | } 71 | 72 | static getType(type) { 73 | return typePluginMap[type]; 74 | } 75 | 76 | static getTypeNames() { 77 | return Object.keys(typePluginMap); 78 | } 79 | } 80 | 81 | exports.default = PluginManager; 82 | PluginManager.registerType("WebClient", new _webClient2.default()); 83 | PluginManager.registerType("IcyServer", new _icyServer2.default()); 84 | PluginManager.registerType("Speaker", new _speaker2.default()); -------------------------------------------------------------------------------- /dist/server/plugins/speaker/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _lame = require("lame"); 8 | 9 | var _lame2 = _interopRequireDefault(_lame); 10 | 11 | var _speaker = require("speaker"); 12 | 13 | var _speaker2 = _interopRequireDefault(_speaker); 14 | 15 | var _station = require("../../station"); 16 | 17 | var _station2 = _interopRequireDefault(_station); 18 | 19 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 20 | 21 | class SpeakerType { 22 | activate(options) { 23 | options = options || {}; 24 | 25 | this.stationOptions = options.stationOptions || {}; 26 | this.station = options.station || new _station2.default(this.stationOptions); 27 | 28 | this.decoder = options.decoder || new _lame2.default.Decoder(); 29 | this.speaker = options.speaker || new _speaker2.default(); 30 | 31 | this.station.on("data", data => { 32 | if (data && data.length) { 33 | this.decoder.write(data); 34 | } 35 | }); 36 | 37 | this.decoder.pipe(this.speaker); 38 | } 39 | } 40 | exports.default = SpeakerType; -------------------------------------------------------------------------------- /dist/server/plugins/web-client/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _events = require("events"); 8 | 9 | var _http = require("http"); 10 | 11 | var _path = require("path"); 12 | 13 | var _path2 = _interopRequireDefault(_path); 14 | 15 | var _express = require("express"); 16 | 17 | var _express2 = _interopRequireDefault(_express); 18 | 19 | var _socket = require("socket.io"); 20 | 21 | var _socket2 = _interopRequireDefault(_socket); 22 | 23 | var _station = require("../../station"); 24 | 25 | var _station2 = _interopRequireDefault(_station); 26 | 27 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 28 | 29 | class WebClient extends _events.EventEmitter { 30 | activate(options) { 31 | options = options || {}; 32 | 33 | this.app = options.app || (0, _express2.default)(); 34 | this.socket = options.socket || new _http.Server(this.app); 35 | this.port = options.port || 8000; 36 | this.rootPath = options.rootPath || "/web"; 37 | this.playerSourcePath = options.playerSourcePath || "/"; 38 | this.staticFolderPath = options.staticFolderPath || _path2.default.join(__dirname, "../../../", "./client"); 39 | this.jspmPath = options.jspmPath || _path2.default.join(__dirname, "../../../"); 40 | this.jspmPackagesPath = this.jspmPackagesPath || _path2.default.join(this.jspmPath, "./jspm_packages"); 41 | this.jspmConfigPath = this.jspmConfigPath || _path2.default.join(this.jspmPath, "./jspm.config.js"); 42 | this.stationOptions = options.stationOptions || {}; 43 | this.station = options.station || new _station2.default(this.stationOptions); 44 | 45 | this.webRouter = new _express2.default.Router(); 46 | this.webRouter.use(_express2.default.static(fixWindowsPath(this.staticFolderPath))); 47 | this.webRouter.use("/jspm_packages", _express2.default.static(fixWindowsPath(this.jspmPackagesPath))); 48 | this.app.use(fixWindowsPath(_path2.default.join("/", this.rootPath)), this.webRouter); 49 | 50 | this.jspmRouter = new _express2.default.Router(); 51 | this.jspmRouter.use(_express2.default.static(fixWindowsPath(this.jspmPackagesPath))); 52 | this.app.use("/jspm_packages", this.jspmRouter); 53 | this.app.get("/jspm.config.js", (req, res) => res.sendFile(fixWindowsPath(this.jspmConfigPath))); 54 | 55 | this.webSocketClients = []; 56 | // TODO: allow for socket.io 57 | this.io = (0, _socket2.default)(this.socket, { 58 | path: fixWindowsPath(_path2.default.join("/", this.rootPath, "/sockets")) 59 | }).on("connection", clientSocket => { 60 | this.webSocketClients.push(clientSocket); 61 | this.emit("webSocketClientConnect", clientSocket); 62 | 63 | clientSocket.once("disconnect", () => { 64 | this.webSocketClients.splice(this.webSocketClients.indexOf(clientSocket), 1); 65 | this.emit("webSocketClientDisconnect", clientSocket); 66 | }).on("fetch", () => { 67 | clientSocket.emit("info", { 68 | item: this.station.item, 69 | metadata: this.station.metadata, 70 | playlists: this.station.playlists, 71 | playerSourcePath: this.playerSourcePath 72 | }); 73 | }).on("next", () => { 74 | this.station.replaceNext(); 75 | }).on("addItem", item => { 76 | // TODO: item validation 77 | this.station.addItem(item); 78 | }).on("addPlaylist", () => { 79 | this.station.addPlaylist(); 80 | }).on("playItem", (id, playlistId) => { 81 | this.station.replacePlaylistByPlaylistIdAndItemId(playlistId, id); 82 | }).on("playPlaylist", playlistId => { 83 | this.station.replacePlaylistByPlaylistId(playlistId); 84 | }).on("removeItem", (id, playlistId) => { 85 | this.station.removeItem(id, playlistId); 86 | }).on("removePlaylist", playlistId => { 87 | this.station.removePlaylist(playlistId); 88 | }); 89 | }); 90 | 91 | this.station.on("play", (item, metadata) => { 92 | this.webSocketClients.forEach(clientSocket => { 93 | clientSocket.emit("playing", item, metadata); 94 | }); 95 | }).on("playlistCreated", playlist => { 96 | this.webSocketClients.forEach(clientSocket => { 97 | clientSocket.emit("playlistCreated", playlist); 98 | }); 99 | }).on("itemCreated", (item, playlist) => { 100 | this.webSocketClients.forEach(clientSocket => { 101 | clientSocket.emit("itemCreated", item, playlist._id); 102 | }); 103 | }).on("itemRemoved", (item, playlist) => { 104 | this.webSocketClients.forEach(clientSocket => { 105 | clientSocket.emit("itemRemoved", item._id, playlist._id); 106 | }); 107 | }).on("playlistRemoved", playlist => { 108 | this.webSocketClients.forEach(clientSocket => { 109 | clientSocket.emit("playlistRemoved", playlist._id); 110 | }); 111 | }); 112 | } 113 | } 114 | 115 | exports.default = WebClient; 116 | function fixWindowsPath(url) { 117 | return url.replace(/\\/g, "/"); 118 | } -------------------------------------------------------------------------------- /dist/server/station/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _events = require("events"); 8 | 9 | var _fluentFfmpeg = require("fluent-ffmpeg"); 10 | 11 | var _fluentFfmpeg2 = _interopRequireDefault(_fluentFfmpeg); 12 | 13 | var _stream = require("../stream"); 14 | 15 | var _stream2 = _interopRequireDefault(_stream); 16 | 17 | var _storage = require("../storage"); 18 | 19 | var _storage2 = _interopRequireDefault(_storage); 20 | 21 | var _playlist = require("../playlist"); 22 | 23 | var _playlist2 = _interopRequireDefault(_playlist); 24 | 25 | var _metadata = require("./metadata"); 26 | 27 | var _metadata2 = _interopRequireDefault(_metadata); 28 | 29 | var _destroy = require("destroy"); 30 | 31 | var _destroy2 = _interopRequireDefault(_destroy); 32 | 33 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 34 | 35 | class Station extends _events.EventEmitter { 36 | constructor(options) { 37 | super(); 38 | 39 | options = options || {}; 40 | this.playlists = options.playlists || []; 41 | this.bufferSize = options.bufferSize || null; 42 | this.dataInterval = options.dataInterval || null; 43 | this.prebufferSize = options.prebufferSize || null; 44 | this.postProcessingBitRate = options.postProcessingBitRate || 128; 45 | this.storageType = options.storageType || "JSON"; 46 | this.ffmpegPath = options.ffmpegPath || null; 47 | 48 | this.ffmpegPath && _fluentFfmpeg2.default.setFfmpegPath(this.ffmpegPath); 49 | this.storage = new _storage2.default(this.storageType); 50 | 51 | this.itemId = null; 52 | this.item = null; 53 | this.metadata = null; 54 | 55 | this.stream = new _stream2.default({ 56 | bufferSize: this.bufferSize, 57 | dataInterval: this.dataInterval, 58 | prebufferSize: this.prebufferSize, 59 | needMoreData: this.streamNeedsMoreData.bind(this) 60 | }); 61 | this.stream.once("data", () => this.emit("start")); 62 | this.stream.on("data", (data, metadata, item) => { 63 | if (this.itemId !== item._id) { 64 | this.itemId = item._id; 65 | this.item = item; 66 | this.metadata = metadata; 67 | this.emit("play", item, metadata); 68 | } 69 | 70 | this.emit("data", data, metadata, item); 71 | }); 72 | 73 | this.playlistPlay = this.playlistPlay.bind(this); 74 | this.playlistReplace = this.playlistReplace.bind(this); 75 | 76 | this.playlists = this.playlists.map(items => new _playlist2.default({ 77 | items: items 78 | })); 79 | } 80 | 81 | start() { 82 | this.storage.activate({}, err => { 83 | if (err) return console.log(err); 84 | this.storage.fill(this.playlists, () => { 85 | this.storage.findAll((err, playlists) => { 86 | if (err) return console.log(err); 87 | this.playlists = playlists; 88 | 89 | this.stream.start(); 90 | }); 91 | }); 92 | }); 93 | } 94 | 95 | addPlaylist(playlist) { 96 | playlist = this.preparePlaylist(playlist); 97 | this.storage.insert(playlist, err => { 98 | if (err) return console.log(err); 99 | 100 | this.playlists.push(playlist); 101 | 102 | this.emit("playlistCreated", playlist); 103 | 104 | if (!this.playlist) { 105 | this.handleNoPlaylist(); 106 | } 107 | }); 108 | } 109 | 110 | addItem(item) { 111 | const playlist = this.playlist; 112 | if (playlist) { 113 | const wasPlaylistEmpty = playlist.items.length < 1; 114 | item = playlist.addItem(item); 115 | 116 | this.storage.update(playlist, err => { 117 | // TODO: remove item if err 118 | if (err) return console.log(err); 119 | 120 | this.emit("itemCreated", item, playlist); 121 | 122 | if (wasPlaylistEmpty) { 123 | this.playNext(); 124 | } 125 | }); 126 | } else { 127 | // TODO: create playlist with item in it 128 | console.log("NYI"); 129 | } 130 | } 131 | 132 | removeItem(id, playlistId) { 133 | const playlist = this.findPlaylistById(playlistId); 134 | if (playlist) { 135 | const removed = playlist.removeItem(id); 136 | const itemIndex = playlist.items.indexOf(removed); 137 | if (removed) { 138 | this.storage.update(playlist, err => { 139 | if (err) { 140 | playlist.items.splice(itemIndex, 0, removed); 141 | return console.error(err); 142 | } 143 | 144 | this.emit("itemRemoved", removed, playlist); 145 | 146 | if (removed._id === this.itemId) { 147 | this.replaceNext(); 148 | } 149 | }); 150 | } else { 151 | console.log("item to remove not found"); 152 | } 153 | } else { 154 | console.log("playlist not found"); 155 | } 156 | } 157 | 158 | removePlaylist(playlistId) { 159 | const playlist = this.findPlaylistById(playlistId); 160 | if (playlist) { 161 | const playlistIndex = this.playlists.indexOf(playlist); 162 | this.playlists.splice(playlistIndex, 1); 163 | this.storage.remove(playlist._id, err => { 164 | if (err) { 165 | this.playlists.splice(playlistIndex, 0, playlist); 166 | return console.error(err); 167 | } 168 | 169 | this.emit("playlistRemoved", playlist); 170 | 171 | if (playlist._id === this.playlist._id) { 172 | this.playlist = null; 173 | this.replaceNext(); 174 | } 175 | }); 176 | } else { 177 | console.log("playlist to remove not found"); 178 | } 179 | } 180 | 181 | preparePlaylist(playlist) { 182 | playlist = playlist || []; 183 | if (Array.isArray(playlist)) { 184 | return new _playlist2.default(playlist); 185 | } else { 186 | return playlist; 187 | } 188 | } 189 | 190 | replacePlaylistByPlaylistId(playlistId) { 191 | const playlist = this.findPlaylistById(playlistId); 192 | if (playlist) this.replacePlaylist(playlist); 193 | } 194 | 195 | replacePlaylistByPlaylistIdAndItemId(playlistId, itemId) { 196 | const playlist = this.findPlaylistById(playlistId); 197 | if (playlist) { 198 | this.replacePlaylistAndItemId(playlist, itemId); 199 | } 200 | } 201 | 202 | replacePlaylist(playlist) { 203 | this.changePlaylist(playlist); 204 | this.replaceNext(); 205 | } 206 | 207 | replacePlaylistAndItemId(playlist, itemId) { 208 | this.changePlaylist(playlist); 209 | this.replaceItemId(itemId); 210 | } 211 | 212 | changePlaylist(playlist) { 213 | if (this.playlist && this.playlist._id === playlist._id) return; 214 | 215 | if (this.playlist) { 216 | this.playlist.removeListener("play", this.playlistPlay); 217 | this.playlist.removeListener("replace", this.playlistReplace); 218 | } 219 | this.playlist = playlist; 220 | this.playlist.on("play", this.playlistPlay); 221 | this.playlist.on("replace", this.playlistReplace); 222 | } 223 | 224 | findPlaylistById(id) { 225 | return this.playlists.find(playlist => { 226 | return playlist._id === id; 227 | }); 228 | } 229 | 230 | playNext() { 231 | if (this.playlist) { 232 | this.handleNothingToPlay(!this.playlist.playNext()); 233 | } else { 234 | this.handleNoPlaylist(); 235 | } 236 | } 237 | 238 | replaceNext() { 239 | if (this.playlist) { 240 | this.handleNothingToPlay(!this.playlist.replaceNext()); 241 | } else { 242 | this.handleNoPlaylist(); 243 | } 244 | } 245 | 246 | replaceItemId(itemId) { 247 | if (this.playlist) { 248 | const canPlay = !this.playlist.replaceItemByItemId(itemId); 249 | if (!canPlay) { 250 | this.replaceNext(); 251 | } 252 | } else { 253 | this.handleNoPlaylist(); 254 | } 255 | } 256 | 257 | handleNothingToPlay(isPlaylistEmpty) { 258 | if (isPlaylistEmpty) { 259 | this.emit("nothingToPlay", this.playlist); 260 | } 261 | } 262 | 263 | handleNoPlaylist() { 264 | if (this.playlists.length > 0) { 265 | this.replacePlaylist(this.playlists[0]); 266 | } else { 267 | this.emit("nothingToPlay", this.playlist); 268 | } 269 | } 270 | 271 | streamNeedsMoreData() { 272 | this.playNext(); 273 | } 274 | 275 | playlistPlay(err, stream, metadata, item, options) { 276 | if (err) return this.onPlayError(err); 277 | options = options || {}; 278 | 279 | this.handleStreamError(stream); 280 | 281 | stream = this.handlePostProcessing(stream, options); 282 | metadata = new _metadata2.default(metadata); 283 | this.stream.next(stream, metadata, item); 284 | } 285 | 286 | playlistReplace(err, stream, metadata, item, options) { 287 | if (err) return this.onPlayError(err); 288 | options = options || {}; 289 | 290 | this.handleStreamError(stream); 291 | 292 | stream = this.handlePostProcessing(stream, options); 293 | metadata = new _metadata2.default(metadata); 294 | this.stream.replace(stream, metadata, item); 295 | } 296 | 297 | onPlayError(err) { 298 | this.emit("error", err); 299 | console.log("trying to play next item..."); 300 | this.playNext(); 301 | } 302 | 303 | handleStreamError(stream) { 304 | return stream.once("error", err => { 305 | (0, _destroy2.default)(stream); 306 | this.onPlayError(err); 307 | }); 308 | } 309 | 310 | handlePostProcessing(stream, options) { 311 | options = options || {}; 312 | 313 | if (options.streamNeedsPostProcessing) { 314 | stream = (0, _fluentFfmpeg2.default)(stream).audioBitrate(this.postProcessingBitRate).format("mp3"); 315 | } 316 | 317 | return this.handleStreamError(stream); 318 | } 319 | } 320 | exports.default = Station; -------------------------------------------------------------------------------- /dist/server/station/metadata.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | const METADATA_BLOCK_SIZE = 16; 7 | const METADATA_LIMIT = METADATA_BLOCK_SIZE * 255; 8 | 9 | class Metadata { 10 | constructor(options) { 11 | this.options = options; 12 | this.text = this.formatText(); 13 | if (this.text.length > METADATA_LIMIT) throw new Error("metadata text length is more than " + METADATA_LIMIT); 14 | 15 | this.chars = Metadata.ensureBlockSize(this.textToBytes()); 16 | this.length = this.chars.length / METADATA_BLOCK_SIZE; 17 | } 18 | 19 | formatText() { 20 | const keys = Object.keys(this.options); 21 | return keys.map(key => { 22 | return key + "='" + this.options[key] + "'"; 23 | }).join(",") + ";"; 24 | } 25 | 26 | textToBytes() { 27 | return this.text.split("").map(function (x) { 28 | return x.charCodeAt(0); 29 | }); 30 | } 31 | 32 | createCombinedBuffer(buffer) { 33 | return Buffer.concat([buffer, new Buffer([this.length]), new Buffer(this.chars)]); 34 | } 35 | 36 | toJSON() { 37 | return this.options; 38 | } 39 | 40 | static ensureBlockSize(chars) { 41 | const rest = METADATA_BLOCK_SIZE - chars.length % METADATA_BLOCK_SIZE; 42 | if (rest < METADATA_BLOCK_SIZE) { 43 | for (let i = 0; i < rest; i++) { 44 | chars.push(0); 45 | } 46 | } 47 | return chars; 48 | } 49 | } 50 | exports.default = Metadata; -------------------------------------------------------------------------------- /dist/server/storage/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _playlist = require("../playlist"); 8 | 9 | var _playlist2 = _interopRequireDefault(_playlist); 10 | 11 | var _json = require("./types/json"); 12 | 13 | var _json2 = _interopRequireDefault(_json); 14 | 15 | var _memory = require("./types/memory"); 16 | 17 | var _memory2 = _interopRequireDefault(_memory); 18 | 19 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 20 | 21 | const typePluginMap = {}; 22 | 23 | class Storage { 24 | constructor(type) { 25 | const typePlugin = Storage.getType(type); 26 | if (!typePlugin) throw new Error("Unknown storage type"); 27 | this.type = type; 28 | this.typePlugin = typePlugin; 29 | } 30 | 31 | activate(options, done) { 32 | this.typePlugin.activate(options, done); 33 | } 34 | 35 | fill(playlists, done) { 36 | if (!playlists) return done(); 37 | if (playlists.length <= 0) return done(); 38 | if (!this.typePlugin.isFillable) { 39 | console.warn("storage type is not fillable"); 40 | return done(); 41 | } 42 | 43 | playlists = mapPlaylists(playlists); 44 | this.typePlugin.fill(playlists, done); 45 | } 46 | 47 | findAll(done) { 48 | this.typePlugin.findAll(mapObjects(done)); 49 | } 50 | 51 | insert(playlist, done) { 52 | this.typePlugin.insert(playlist, done); 53 | } 54 | 55 | update(playlist, done) { 56 | this.typePlugin.update(playlist, done); 57 | } 58 | 59 | remove(id, done) { 60 | this.typePlugin.remove(id, done); 61 | } 62 | 63 | static registerType(type, typePlugin) { 64 | typePluginMap[type] = typePlugin; 65 | } 66 | 67 | static getType(type) { 68 | return typePluginMap[type]; 69 | } 70 | 71 | static getTypeNames() { 72 | return Object.keys(typePluginMap); 73 | } 74 | } 75 | 76 | exports.default = Storage; 77 | Storage.registerType("JSON", new _json2.default()); 78 | Storage.registerType("Memory", new _memory2.default()); 79 | 80 | function mapObjects(done) { 81 | return function (err, playlists) { 82 | if (err) return done(err); 83 | if (!Array.isArray(playlists)) return done(null, new _playlist2.default(playlists)); 84 | 85 | done(null, playlists.map(playlist => new _playlist2.default(playlist))); 86 | }; 87 | } 88 | 89 | function mapPlaylists(playlists) { 90 | if (!Array.isArray(playlists)) return mapPlaylist(playlists); 91 | 92 | return playlists.map(playlist => mapPlaylist(playlist)); 93 | } 94 | 95 | function mapPlaylist(playlist) { 96 | return playlist.toJSON ? playlist.toJSON() : playlist; 97 | } -------------------------------------------------------------------------------- /dist/server/storage/types/json.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _fs = require("fs"); 8 | 9 | var _fs2 = _interopRequireDefault(_fs); 10 | 11 | var _path = require("path"); 12 | 13 | var _path2 = _interopRequireDefault(_path); 14 | 15 | var _mkdirp = require("mkdirp"); 16 | 17 | var _mkdirp2 = _interopRequireDefault(_mkdirp); 18 | 19 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 20 | 21 | class JSONType { 22 | constructor() {} 23 | 24 | activate(options, done) { 25 | this.folder = options.folder || "./json"; 26 | (0, _mkdirp2.default)(this.folder, done); 27 | } 28 | 29 | findAll(done) { 30 | _fs2.default.readdir(this.folder, (err, files) => { 31 | const playlists = files.map(playlistFile => { 32 | const filename = _path2.default.join(this.folder, playlistFile); 33 | const data = _fs2.default.readFileSync(filename).toString(); 34 | return JSON.parse(data); 35 | }); 36 | done && done(null, playlists); 37 | }); 38 | } 39 | 40 | insert(playlist, done) { 41 | const jsonString = JSON.stringify(playlist, null, 2); 42 | const filename = _path2.default.join(this.folder, playlist._id + ".json"); 43 | _fs2.default.writeFile(filename, jsonString, err => { 44 | if (err) return done && done(err); 45 | done && done(); 46 | }); 47 | } 48 | 49 | update(playlist, done) { 50 | this.insert(playlist, done); 51 | } 52 | 53 | remove(id, done) { 54 | const filename = _path2.default.join(this.folder, id + ".json"); 55 | _fs2.default.unlink(filename, err => { 56 | if (err) return done && done(err); 57 | done && done(); 58 | }); 59 | } 60 | } 61 | exports.default = JSONType; -------------------------------------------------------------------------------- /dist/server/storage/types/memory.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | class MemoryType { 7 | constructor() { 8 | this.isFillable = true; 9 | } 10 | 11 | activate(options, done) { 12 | this.playlistIdMap = {}; 13 | done && done(); 14 | } 15 | 16 | fill(playlists, done) { 17 | playlists.forEach(playlist => this.insert(playlist)); 18 | done && done(); 19 | } 20 | 21 | findAll(done) { 22 | const playlists = Object.keys(this.playlistIdMap).map(playlistId => this.playlistIdMap[playlistId]); 23 | done && done(null, playlists); 24 | } 25 | 26 | insert(playlist, done) { 27 | this.playlistIdMap[playlist._id] = playlist; 28 | done && done(); 29 | } 30 | 31 | update(playlist, done) { 32 | this.playlistIdMap[playlist._id] = playlist; 33 | done && done(); 34 | } 35 | 36 | remove(id, done) { 37 | delete this.playlistIdMap[id]; 38 | done && done(); 39 | } 40 | } 41 | exports.default = MemoryType; -------------------------------------------------------------------------------- /dist/server/stream/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _events = require("events"); 8 | 9 | var _info = require("./info"); 10 | 11 | var _info2 = _interopRequireDefault(_info); 12 | 13 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 14 | 15 | class Stream extends _events.EventEmitter { 16 | constructor(options) { 17 | super(); 18 | 19 | options = options || {}; 20 | this.bufferSize = options.bufferSize || 8192; 21 | this.prebufferSize = options.prebufferSize || this.bufferSize * 20; 22 | this.dataInterval = options.dataInterval || 500; 23 | this.needMoreData = options.needMoreData || function () {}; 24 | this.streamInfos = []; 25 | } 26 | 27 | start() { 28 | this.dataLoop = this.dataLoop.bind(this); 29 | this.dataLoop(); 30 | } 31 | 32 | dataLoop() { 33 | this.checkNextStream(); 34 | 35 | const streamInfo = this.streamInfos[0]; 36 | if (streamInfo && streamInfo.isHeaderPresent) { 37 | const bufferAmountToSend = streamInfo.calculateBufferAmountToSend(this.bufferSize); 38 | for (let i = 0; i < bufferAmountToSend; i++) { 39 | const previousBuffer = this.previousBuffer || new Buffer([]); 40 | const bufferToSend = this.collectRealtimeBuffer(previousBuffer, this.bufferSize); 41 | if (bufferToSend) { 42 | if (bufferToSend.length >= this.bufferSize) { 43 | this.previousBuffer = null; 44 | this.emit("data", bufferToSend, streamInfo.metadata, streamInfo.item); 45 | } else { 46 | this.previousBuffer = bufferToSend; 47 | console.log("buffer to small"); 48 | } 49 | } else { 50 | console.log("no buffer"); 51 | } 52 | } 53 | } 54 | 55 | setTimeout(this.dataLoop, this.dataInterval); 56 | } 57 | 58 | collectRealtimeBuffer(realtimeBuffer) { 59 | const streamInfo = this.streamInfos[0]; 60 | if (realtimeBuffer.length >= this.bufferSize) return realtimeBuffer; 61 | if (!streamInfo) return realtimeBuffer; 62 | 63 | const missingBytes = this.bufferSize - realtimeBuffer.length; 64 | realtimeBuffer = Buffer.concat([realtimeBuffer, streamInfo.cutBeginOfBuffer(missingBytes)]); 65 | 66 | streamInfo.processedBytes += realtimeBuffer.length; 67 | 68 | if (realtimeBuffer.length < this.bufferSize) { 69 | const streamInfo = this.streamInfos.shift(); 70 | streamInfo.destroy(); 71 | return this.collectRealtimeBuffer(realtimeBuffer); 72 | } 73 | 74 | return realtimeBuffer; 75 | } 76 | 77 | getRealtimeBufferSize() { 78 | return this.streamInfos.map(streamInfo => streamInfo.buffer.length).reduce((previous, length) => { 79 | return previous + length; 80 | }, 0); 81 | } 82 | 83 | next(stream, metadata, item) { 84 | this.streamInfos.push(new _info2.default(stream, metadata, item)); 85 | this.checkNextStream(); 86 | } 87 | 88 | replace(stream, metadata, item) { 89 | this.streamInfos.forEach(streamInfo => streamInfo.destroy()); 90 | this.streamInfos = [new _info2.default(stream, metadata, item)]; 91 | this.didAlreadyRequest = true; 92 | this.checkNextStream(); 93 | } 94 | 95 | checkNextStream() { 96 | if (!this.isCollectingData() && this.getRealtimeBufferSize() <= this.prebufferSize) { 97 | const streamInfo = this.getCollectableStreamInfo(); 98 | if (streamInfo) { 99 | streamInfo.once("collected", () => { 100 | this.didAlreadyRequest = false; 101 | }); 102 | streamInfo.collect(); 103 | } else { 104 | if (!this.didAlreadyRequest) { 105 | this.didAlreadyRequest = true; 106 | this.needMoreData(); 107 | } 108 | } 109 | } 110 | } 111 | 112 | getCollectableStreamInfo() { 113 | return this.streamInfos.find(streamInfo => { 114 | return !streamInfo.isCollected; 115 | }); 116 | } 117 | 118 | isCollectingData() { 119 | return this.streamInfos.some(streamInfo => { 120 | return streamInfo.isCollecting; 121 | }); 122 | } 123 | } 124 | exports.default = Stream; -------------------------------------------------------------------------------- /dist/server/stream/info.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _events = require("events"); 8 | 9 | var _smParsers = require("sm-parsers"); 10 | 11 | var _destroy = require("destroy"); 12 | 13 | var _destroy2 = _interopRequireDefault(_destroy); 14 | 15 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 16 | 17 | class StreamInfo extends _events.EventEmitter { 18 | constructor(stream, metadata, item) { 19 | super(); 20 | 21 | this.stream = stream; 22 | this.metadata = metadata; 23 | this.item = item; 24 | 25 | this.isCollected = false; 26 | this.isCollecting = false; 27 | this.buffer = new Buffer([]); 28 | this.parser = new _smParsers.MP3(); 29 | this.processedBytes = 0; 30 | 31 | this.stream.once("end", () => { 32 | this.isCollected = true; 33 | this.isCollecting = false; 34 | 35 | this.emit("collected", this); 36 | }); 37 | 38 | this.parser.once("frame", (data, firstHeader) => { 39 | this.setHeader(firstHeader); 40 | }).on("frame", data => { 41 | this.appendToBuffer(data); 42 | }); 43 | } 44 | 45 | collect() { 46 | this.isCollecting = true; 47 | this.stream.pipe(this.parser); 48 | } 49 | 50 | setHeader(firstHeader) { 51 | this.isHeaderPresent = true; 52 | this.RTC = new Date(); 53 | this.bytesPerMillisecond = firstHeader.frames_per_sec * firstHeader.frameSizeRaw / 1000; 54 | } 55 | 56 | appendToBuffer(data) { 57 | this.buffer = Buffer.concat([this.buffer, data]); 58 | } 59 | 60 | cutBeginOfBuffer(length) { 61 | const removedBuffer = this.buffer.slice(0, length); 62 | this.buffer = this.buffer.slice(length, this.buffer.length); 63 | return removedBuffer; 64 | } 65 | 66 | calculateBufferAmountToSend(bufferSize) { 67 | const millisecondsSinceStart = new Date() - this.RTC; 68 | const bytesSinceStart = millisecondsSinceStart * this.bytesPerMillisecond; 69 | const buffersSinceStart = Math.floor(bytesSinceStart / bufferSize); 70 | const processedBuffers = Math.floor(this.processedBytes / bufferSize); 71 | return buffersSinceStart - processedBuffers; 72 | } 73 | 74 | destroy() { 75 | (0, _destroy2.default)(this.stream); 76 | this.stream.removeAllListeners(); 77 | this.stream = null; 78 | 79 | (0, _destroy2.default)(this.parser); 80 | this.parser.removeAllListeners(); 81 | this.parser = null; 82 | } 83 | } 84 | exports.default = StreamInfo; -------------------------------------------------------------------------------- /docs/images/jsCast-Web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ardean/jsCast/8bfea53b3a5d7fce5b24fd39629fb6d63ae35ae2/docs/images/jsCast-Web.png -------------------------------------------------------------------------------- /install-rpi-ffmpeg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | git clone --depth=1 git://source.ffmpeg.org/ffmpeg.git 3 | cd ffmpeg 4 | ./configure --arch=armel --target-os=linux --enable-gpl --enable-nonfree --enable-libmp3lame 5 | make -j4 6 | sudo make install 7 | -------------------------------------------------------------------------------- /jspm.config.js: -------------------------------------------------------------------------------- 1 | SystemJS.config({ 2 | paths: { 3 | "npm:": "jspm_packages/npm/", 4 | "github:": "jspm_packages/github/" 5 | }, 6 | browserConfig: { 7 | "baseURL": "/", 8 | "paths": { 9 | "jscast/": "src/" 10 | } 11 | }, 12 | nodeConfig: { 13 | "paths": { 14 | "jscast/": "js/" 15 | } 16 | }, 17 | devConfig: { 18 | "map": { 19 | "plugin-babel": "npm:systemjs-plugin-babel@0.0.20" 20 | } 21 | }, 22 | transpiler: "plugin-babel", 23 | packages: { 24 | "jscast": { 25 | "main": "index.js", 26 | "meta": { 27 | "*.js": { 28 | "loader": "plugin-babel" 29 | } 30 | } 31 | } 32 | } 33 | }); 34 | 35 | SystemJS.config({ 36 | packageConfigPaths: [ 37 | "npm:@*/*.json", 38 | "npm:*.json", 39 | "github:*/*.json" 40 | ], 41 | map: { 42 | "assert": "npm:jspm-nodelibs-assert@0.2.0", 43 | "events": "github:jspm/nodelibs-events@0.1.1", 44 | "jquery": "npm:jquery@3.1.1", 45 | "knockout": "github:knockout/knockout@3.4.1", 46 | "process": "npm:jspm-nodelibs-process@0.2.0", 47 | "util": "npm:jspm-nodelibs-util@0.2.1" 48 | }, 49 | packages: { 50 | "github:jspm/nodelibs-events@0.1.1": { 51 | "map": { 52 | "events": "npm:events@1.0.2" 53 | } 54 | } 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jscast", 3 | "version": "0.5.0", 4 | "description": "An Audio Streaming Application written in JavaScript", 5 | "author": "ardean", 6 | "license": "MIT", 7 | "keywords": [ 8 | "blacklist", 9 | "cli", 10 | "ffmpeg", 11 | "icecast", 12 | "jscast", 13 | "json", 14 | "radio", 15 | "radio-station", 16 | "station", 17 | "storage-types", 18 | "stream", 19 | "whitelist", 20 | "youtube" 21 | ], 22 | "repository": "https://github.com/ardean/jsCast", 23 | "bugs": "https://github.com/ardean/jsCast/issues", 24 | "main": "dist/index.js", 25 | "dependencies": { 26 | "async": "^2.0.1", 27 | "commander": "^2.9.0", 28 | "destroy": "^1.0.4", 29 | "express": "^4.14.0", 30 | "fluent-ffmpeg": "^2.1.0", 31 | "geoip-lite": "^1.1.8", 32 | "ip": "^1.1.3", 33 | "lame": "^1.2.4", 34 | "mkdirp": "^0.5.1", 35 | "shortid": "^2.2.6", 36 | "sm-parsers": "^0.1.2", 37 | "socket.io": "^1.4.8", 38 | "speaker": "^0.3.0", 39 | "ytdl-core": "^0.8.0" 40 | }, 41 | "devDependencies": { 42 | "babel-cli": "^6.11.4", 43 | "babel-preset-node6": "^11.0.0", 44 | "cpy-cli": "^1.0.1", 45 | "del-cli": "^0.2.1", 46 | "jspm": "^0.17.0-beta.38" 47 | }, 48 | "scripts": { 49 | "start": "babel-node demo", 50 | "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,Client", 51 | "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", 52 | "build": "npm run cleanup && npm run build-server && npm run build-client", 53 | "build-server": "babel src --out-dir dist/server", 54 | "build-client": "npm run build-js && npm run build-html && npm run build-css", 55 | "build-js": "jspm build client/js/index.js dist/client/index.js --minify --skip-source-maps", 56 | "build-css": "cpy client/css/**/*.css dist/client/css", 57 | "build-html": "cpy client/*.html dist/client", 58 | "cleanup": "del-cli dist" 59 | }, 60 | "bin": { 61 | "jsCast": "./bin/index.js" 62 | }, 63 | "jspm": { 64 | "name": "jscast", 65 | "main": "client/js/index.js", 66 | "dependencies": { 67 | "events": "github:jspm/nodelibs-events@^0.1.1", 68 | "jquery": "npm:jquery@^3.1.1", 69 | "knockout": "github:knockout/knockout@^3.4.1" 70 | }, 71 | "devDependencies": { 72 | "plugin-babel": "npm:systemjs-plugin-babel@^0.0.20" 73 | }, 74 | "peerDependencies": { 75 | "assert": "npm:jspm-nodelibs-assert@^0.2.0", 76 | "process": "npm:jspm-nodelibs-process@^0.2.0", 77 | "util": "npm:jspm-nodelibs-util@^0.2.0" 78 | }, 79 | "overrides": { 80 | "github:knockout/knockout@3.4.1": { 81 | "main": "dist/knockout.debug", 82 | "meta": { 83 | "dist/knockout.debug.js": { 84 | "exports": "ko", 85 | "format": "global" 86 | } 87 | } 88 | }, 89 | "npm:jquery@3.1.1": { 90 | "format": "amd" 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import ip from "ip"; 2 | import jsCast from "./"; 3 | import { log } from "util"; 4 | import geoip from "geoip-lite"; 5 | import Storage from "./storage"; 6 | import program from "commander"; 7 | import { version } from "./package"; 8 | import PluginManager from "./plugins"; 9 | 10 | const allStorageTypeNames = Storage.getTypeNames(); 11 | const allPluginTypeNames = PluginManager.getTypeNames(); 12 | 13 | program 14 | .version(version) 15 | .option("-p, --port [port]", "sets server port", parseInt) 16 | .option("-s, --storage-type [storageType]", "use storage type, built-in types: " + allStorageTypeNames.join(", ")) 17 | .option("-t, --plugin-types [pluginTypes]", "use plugin types, built-in types: " + allPluginTypeNames.join(", "), parseList) 18 | .option("--ffmpeg-path [ffmpegPath]", "path to ffmpeg binary e.g. C:/ffmpeg.exe") 19 | .option("--youtube-items [youtubeItems]", "youtube items to play e.g. URL1,URL2", parseList) 20 | .option("--whitelist [whitelist]", "country whitelist e.g. US,DE", parseList) 21 | .option("--blacklist [blacklist]", "country blacklist e.g. FR,IT", parseList) 22 | .parse(process.argv); 23 | 24 | const whitelist = program.whitelist; 25 | const blacklist = program.blacklist; 26 | const playlists = []; 27 | const playlist = (program.youtubeItems || []).map((item) => mapYouTubeList(item)); 28 | 29 | if (playlist.length) { 30 | playlists.push(playlist); 31 | } 32 | 33 | const jsCastOptions = { 34 | stationOptions: { 35 | storageType: program.storageType, 36 | ffmpegPath: program.ffmpegPath, 37 | playlists: playlists 38 | }, 39 | pluginManagerOptions: { 40 | types: program.pluginTypes 41 | } 42 | }; 43 | 44 | const instance = jsCast(jsCastOptions) 45 | .on("clientRejected", (client) => { 46 | log(`client ${client.ip} rejected`); 47 | }); 48 | 49 | const icyServer = instance.pluginManager.getActiveType("IcyServer"); 50 | const webClient = instance.pluginManager.getActiveType("WebClient"); 51 | 52 | instance 53 | .station 54 | .on("play", (item, metadata) => { 55 | log(`playing ${metadata.options.StreamTitle}`); 56 | }) 57 | .on("nothingToPlay", (playlist) => { 58 | if (!playlist) { 59 | log("no playlist"); 60 | } else { 61 | log("playlist is empty"); 62 | } 63 | }); 64 | 65 | instance 66 | .start({ 67 | port: program.port, 68 | allow: (client) => { 69 | if (ip.isEqual(client.ip, "127.0.0.1") || client.ip === "::1") return true; 70 | if ( 71 | (!whitelist || !whitelist.length) && 72 | (!blacklist || !blacklist.length) 73 | ) return true; 74 | 75 | const geo = geoip.lookup(client.ip); 76 | return isInCountryList(geo, whitelist) && !isInCountryList(geo, blacklist); 77 | } 78 | }) 79 | .then(() => { 80 | log(`jsCast is running`); 81 | 82 | if (icyServer) { 83 | icyServer 84 | .on("clientConnect", (client) => { 85 | log(`icy client ${client.ip} connected`); 86 | }) 87 | .on("clientDisconnect", (client) => { 88 | log(`icy client ${client.ip} disconnected`); 89 | }); 90 | 91 | log(`listen on http://localhost:${icyServer.port}${icyServer.rootPath}`); 92 | } 93 | 94 | if (webClient) { 95 | log(`Web Client on http://localhost:${webClient.port}${webClient.rootPath}`); 96 | } 97 | }) 98 | .catch((err) => { 99 | console.error(err); 100 | }); 101 | 102 | function mapYouTubeList(url) { 103 | return { 104 | type: "YouTube", 105 | options: { 106 | url: url 107 | } 108 | }; 109 | } 110 | 111 | function isInCountryList(geo, list) { 112 | return geo && list && list.length && list.some((country) => country === geo.country); 113 | } 114 | 115 | function parseList(data) { 116 | return (data || "").split(","); 117 | } 118 | -------------------------------------------------------------------------------- /src/client/client-allow-middleware.js: -------------------------------------------------------------------------------- 1 | export default function (allow, rejected) { 2 | return function (req, res, next) { 3 | const client = req.jscastClient; 4 | if (client) { 5 | if (!allow(client)) { // TODO: allow promise 6 | rejected(client); 7 | return res.sendStatus(404); 8 | } else { 9 | next(); 10 | } 11 | } else { 12 | throw new Error("no client object"); 13 | } 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/client/client-middleware.js: -------------------------------------------------------------------------------- 1 | import Client from "./"; 2 | 3 | export default function (req, res, next) { 4 | req.jscastClient = new Client(req, res); 5 | next(); 6 | } 7 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | export default class Client { 2 | constructor(req, res) { 3 | this.req = req; 4 | this.res = res; 5 | this.ip = req.ip; 6 | this.wantsMetadata = req.headers["icy-metadata"] === "1"; 7 | } 8 | 9 | write(data) { 10 | this.res.write(data); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { Server as HttpServer } from "http"; 3 | import express from "express"; 4 | import Stream from "./stream"; 5 | import Station from "./station"; 6 | import Storage from "./storage"; 7 | import PluginManager from "./plugins"; 8 | import Playlist from "./playlist"; 9 | import Item from "./item"; 10 | import clientMiddleware from "./client/client-middleware"; 11 | import allowMiddleware from "./client/client-allow-middleware"; 12 | import { version } from "./package"; 13 | 14 | class JsCast extends EventEmitter { 15 | constructor(options) { 16 | super(); 17 | 18 | options = options || {}; 19 | 20 | this.stationOptions = options.stationOptions || {}; 21 | this.station = options.station || new Station(this.stationOptions); 22 | 23 | this.pluginManagerOptions = options.pluginManagerOptions || {}; 24 | this.pluginManager = new PluginManager(this.pluginManagerOptions); 25 | } 26 | 27 | start(options) { 28 | options = options || {}; 29 | 30 | this.app = options.app || express(); 31 | this.socket = options.socket || new HttpServer(this.app); 32 | this.port = options.port || 8000; 33 | this.allow = options.allow || function () { 34 | return true; 35 | }; 36 | 37 | this.station = options.station || this.station; 38 | this.pluginManager = options.pluginManager || this.pluginManager; 39 | 40 | // TODO: universal (client) middlewares 41 | this.app.use((req, res, next) => { 42 | res.setHeader("x-powered-by", `jsCast v${version} https://github.com/ardean/jsCast`); 43 | next(); 44 | }); 45 | this.app.use(clientMiddleware); 46 | this.app.use(allowMiddleware(this.allow, (client) => { 47 | this.emit("clientRejected", client); 48 | })); 49 | 50 | return this.pluginManager 51 | .activate(this) 52 | .then((options) => { 53 | return new Promise((resolve) => { 54 | if (options.socket && options.port) { 55 | // TODO: listen to socket 56 | this.listen(options.socket, options.port, () => { 57 | resolve(); 58 | }); 59 | } else { 60 | resolve(); 61 | } 62 | }); 63 | }) 64 | .then(() => { 65 | this.station.start(); // TODO: promises 66 | 67 | return this; 68 | }); 69 | } 70 | 71 | listen(socket, port, done) { 72 | if (typeof port === "function") { 73 | done = port; 74 | port = null; 75 | } 76 | 77 | port = this.port = port || this.port; 78 | 79 | this.once("start", () => { 80 | done && done(); 81 | }); 82 | 83 | socket.listen(port, () => { 84 | this.emit("start"); 85 | }); 86 | 87 | return socket; 88 | } 89 | } 90 | 91 | function jsCast(options) { 92 | return new JsCast(options); 93 | } 94 | 95 | export { 96 | jsCast, 97 | JsCast, 98 | Stream, 99 | Station, 100 | Storage, 101 | PluginManager, 102 | Playlist, 103 | Item 104 | }; 105 | 106 | export default jsCast; 107 | -------------------------------------------------------------------------------- /src/item/index.js: -------------------------------------------------------------------------------- 1 | import shortid from "shortid"; 2 | import FileType from "./types/file"; 3 | import StreamType from "./types/stream"; 4 | import YouTubeType from "./types/youtube"; 5 | import destroy from "destroy"; 6 | 7 | const typePluginMap = {}; 8 | 9 | export default class Item { 10 | constructor(options) { 11 | this._id = shortid.generate(); 12 | this.type = options.type; 13 | this.options = options.options; 14 | this.typePlugin = Item.getType(this.type); 15 | } 16 | 17 | load(done) { 18 | this.typePlugin.getStream(this, (err, stream) => { 19 | if (err) return done(err); 20 | stream.once("error", () => {}); 21 | 22 | this.typePlugin.getMetadata(this, (err, metadata) => { 23 | if (err) { 24 | destroy(stream); 25 | return done(err); 26 | } 27 | 28 | done(null, stream, metadata, { 29 | streamNeedsPostProcessing: this.typePlugin.streamNeedsPostProcessing 30 | }); 31 | }); 32 | }); 33 | } 34 | 35 | toJSON() { 36 | return { 37 | _id: this._id, 38 | type: this.type, 39 | options: this.options 40 | }; 41 | } 42 | 43 | static registerType(type, typePlugin) { 44 | typePluginMap[type] = typePlugin; 45 | } 46 | 47 | static getType(type) { 48 | const typePlugin = typePluginMap[type]; 49 | if (!typePlugin) throw new Error("Unknown item type"); 50 | return typePlugin; 51 | } 52 | } 53 | 54 | Item.registerType("File", new FileType()); 55 | Item.registerType("Stream", new StreamType()); 56 | Item.registerType("YouTube", new YouTubeType()); 57 | -------------------------------------------------------------------------------- /src/item/types/file.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export default class FileType { 5 | constructor() { 6 | this.streamNeedsPostProcessing = true; 7 | } 8 | 9 | getStream(item, done) { 10 | done(null, fs.createReadStream(item.options.filename)); 11 | } 12 | 13 | getMetadata(item, done) { 14 | done(null, { 15 | StreamTitle: item.options.title || path.basename(item.options.filename, path.extname(item.options.filename)) 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/item/types/stream.js: -------------------------------------------------------------------------------- 1 | export default class StreamType { 2 | constructor() { 3 | this.streamNeedsPostProcessing = true; 4 | } 5 | 6 | getStream(item, done) { 7 | done(null, item.options.stream); 8 | } 9 | 10 | getMetadata(item, done) { 11 | done(null, { 12 | StreamTitle: item.options.title || "" 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/item/types/youtube.js: -------------------------------------------------------------------------------- 1 | import ytdl from "ytdl-core"; 2 | 3 | export default class YouTubeType { 4 | constructor() { 5 | this.streamNeedsPostProcessing = true; 6 | } 7 | 8 | getStream(item, done) { 9 | done(null, ytdl(item.options.url)); 10 | } 11 | 12 | getMetadata(item, done) { 13 | ytdl.getInfo(item.options.url, (err, info) => { 14 | if (err) return done(err); 15 | done(null, { 16 | StreamTitle: info.author.name + " - " + info.title 17 | }); 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/package.js: -------------------------------------------------------------------------------- 1 | export default { 2 | version: "0.5.0" 3 | } 4 | -------------------------------------------------------------------------------- /src/playlist/index.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import shortid from "shortid"; 3 | import Item from "../item"; 4 | 5 | export default class Playlist extends EventEmitter { 6 | constructor(options) { 7 | super(); 8 | 9 | this._id = options._id || shortid.generate(); 10 | this.items = options.items || []; 11 | this.items = this.items.map((options) => new Item(options)); 12 | this.index = typeof options.index === "number" ? options.index : -1; 13 | } 14 | 15 | addItem(options) { 16 | const item = new Item(options); 17 | this.items.push(item); 18 | return item; 19 | } 20 | 21 | removeItem(id) { 22 | const item = this.findItemById(id); 23 | if (item) { 24 | const index = this.items.indexOf(item); 25 | if (index > -1) { 26 | this.items.splice(index, 1); 27 | return item; 28 | } else { 29 | return false; 30 | } 31 | } else { 32 | return false; 33 | } 34 | } 35 | 36 | findItemById(id) { 37 | return this.items.find((item) => item._id === id); 38 | } 39 | 40 | replaceItemByItemId(itemId) { 41 | const item = this.findItemById(itemId); 42 | return this.replaceItem(item); 43 | } 44 | 45 | replaceItem(item) { 46 | if (!item) return false; 47 | this.loadItem(item, "replace"); 48 | return true; 49 | } 50 | 51 | loadItem(item, eventName) { 52 | item.load((err, stream, metadata, options) => { 53 | this.emit(eventName, err, stream, metadata, item, options); 54 | }); 55 | } 56 | 57 | playNext() { 58 | return this.next("play"); 59 | } 60 | 61 | replaceNext() { 62 | return this.next("replace"); 63 | } 64 | 65 | next(eventName) { 66 | this.setNextIndex(); 67 | const item = this.items[this.index]; 68 | if (!item) return false; 69 | this.loadItem(item, eventName); 70 | return true; 71 | } 72 | 73 | setNextIndex() { 74 | if (this.index + 1 >= this.items.length) { 75 | this.index = 0; 76 | return this.index; 77 | } else { 78 | return ++this.index; 79 | } 80 | } 81 | 82 | toJSON() { 83 | return { 84 | _id: this._id, 85 | items: this.items 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/plugins/icy-server/index.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { Server as HttpServer } from "http"; 3 | import express from "express"; 4 | import Station from "../../station"; 5 | import Client from "../../client"; 6 | 7 | export default class IcyServer extends EventEmitter { 8 | activate(options) { 9 | options = options || {}; 10 | 11 | this.name = options.name || "jsCast - An Audio Streaming Application written in JavaScript"; 12 | this.url = options.url || "https://github.com/ardean/jsCast"; 13 | this.genre = options.genre || "Music"; 14 | this.isPublic = options.isPublic || false; 15 | this.bitrate = options.bitrate || 128; 16 | this.bufferSize = options.bufferSize || 8192; 17 | this.skipMetadata = options.skipMetadata || false; 18 | this.rootPath = options.rootPath || "/"; 19 | 20 | this.stationOptions = options.stationOptions || {}; 21 | this.station = options.station || new Station(this.stationOptions); 22 | this.app = options.app || express(); 23 | this.socket = options.socket || new HttpServer(this.app); 24 | this.port = options.port || 8000; 25 | 26 | this.station.on("data", (data, metadata) => { 27 | if (data) { 28 | let metadataBuffer = data; 29 | 30 | if (!this.skipMetadata) { 31 | metadataBuffer = metadata.createCombinedBuffer(data); 32 | } 33 | 34 | this.clients.forEach((client) => { 35 | const sendMetadata = !this.skipMetadata && client.wantsMetadata; 36 | client.write(sendMetadata ? metadataBuffer : data); 37 | }); 38 | } 39 | }); 40 | 41 | this.clients = []; 42 | this.app.get(this.rootPath, (req, res) => this.clientConnected(new Client(req, res))); 43 | } 44 | 45 | clientConnected(client) { 46 | this.clients.push(client); 47 | this.emit("clientConnect", client); 48 | 49 | client.res.writeHead(200, this.getHeaders(client)); 50 | client.req.once("close", this.clientDisconnected.bind(this, client)); 51 | } 52 | 53 | clientDisconnected(client) { 54 | this.clients.splice(this.clients.indexOf(client), 1); 55 | this.emit("clientDisconnect", client); 56 | } 57 | 58 | getHeaders(client) { 59 | const sendMetadata = !this.skipMetadata && client.wantsMetadata; 60 | return { 61 | "Content-Type": "audio/mpeg", 62 | "icy-name": this.name, 63 | "icy-url": this.url, 64 | "icy-genre": this.genre, 65 | "icy-pub": this.isPublic ? "1" : "0", 66 | "icy-br": this.bitrate.toString(), 67 | "icy-metaint": sendMetadata ? this.bufferSize.toString() : "0" 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/plugins/index.js: -------------------------------------------------------------------------------- 1 | import WebClient from "./web-client"; 2 | import IcyServerType from "./icy-server"; 3 | import SpeakerType from "./speaker"; 4 | 5 | const typePluginMap = {}; 6 | 7 | export default class PluginManager { 8 | constructor(options) { 9 | options = options || {}; 10 | 11 | this.types = options.types || ["WebClient", "IcyServer", "Speaker"]; 12 | this.typePlugins = this.types.map((type) => PluginManager.getType(type)); 13 | } 14 | 15 | activate(options) { 16 | options = options || {}; 17 | 18 | const promises = this.typePlugins.map((typePlugin) => { 19 | const pluginOptions = options[typePlugin.typeName] || {}; 20 | pluginOptions.app = pluginOptions.app || options.app; 21 | pluginOptions.socket = pluginOptions.socket || options.socket; 22 | pluginOptions.port = pluginOptions.port || options.port; 23 | pluginOptions.station = pluginOptions.station || options.station; 24 | 25 | return Promise 26 | .resolve(typePlugin.activate(pluginOptions)) 27 | .then(() => { 28 | if (typePlugin.app) { 29 | pluginOptions.app = pluginOptions.app || typePlugin.app; 30 | options.app = pluginOptions.app; 31 | } 32 | 33 | if (typePlugin.socket) { 34 | pluginOptions.socket = pluginOptions.socket || typePlugin.socket; 35 | options.socket = pluginOptions.socket; 36 | } 37 | }); 38 | }); 39 | 40 | return Promise 41 | .all(promises) 42 | .then(() => { 43 | return options; 44 | }); 45 | } 46 | 47 | isActive(type) { 48 | return this.types.indexOf(type) > -1; 49 | } 50 | 51 | getActiveType(type) { 52 | return this.isActive(type) && PluginManager.getType(type); 53 | } 54 | 55 | static registerType(type, typePlugin) { 56 | typePlugin.typeName = type; 57 | typePluginMap[type] = typePlugin; 58 | } 59 | 60 | static getType(type) { 61 | return typePluginMap[type]; 62 | } 63 | 64 | static getTypeNames() { 65 | return Object.keys(typePluginMap); 66 | } 67 | } 68 | 69 | PluginManager.registerType("WebClient", new WebClient()); 70 | PluginManager.registerType("IcyServer", new IcyServerType()); 71 | PluginManager.registerType("Speaker", new SpeakerType()); 72 | -------------------------------------------------------------------------------- /src/plugins/speaker/index.js: -------------------------------------------------------------------------------- 1 | import lame from "lame"; 2 | import Speaker from "speaker"; 3 | import Station from "../../station"; 4 | 5 | export default class SpeakerType { 6 | activate(options) { 7 | options = options || {}; 8 | 9 | this.stationOptions = options.stationOptions || {}; 10 | this.station = options.station || new Station(this.stationOptions); 11 | 12 | this.decoder = options.decoder || new lame.Decoder(); 13 | this.speaker = options.speaker || new Speaker(); 14 | 15 | this.station.on("data", (data) => { 16 | if (data && data.length) { 17 | this.decoder.write(data); 18 | } 19 | }); 20 | 21 | this.decoder.pipe(this.speaker); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/plugins/web-client/index.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { Server as HttpServer } from "http"; 3 | import path from "path"; 4 | import express from "express"; 5 | import SocketIOServer from "socket.io"; 6 | import Station from "../../station"; 7 | 8 | export default class WebClient extends EventEmitter { 9 | activate(options) { 10 | options = options || {}; 11 | 12 | this.app = options.app || express(); 13 | this.socket = options.socket || new HttpServer(this.app); 14 | this.port = options.port || 8000; 15 | this.rootPath = options.rootPath || "/web"; 16 | this.playerSourcePath = options.playerSourcePath || "/"; 17 | this.staticFolderPath = options.staticFolderPath || path.join(__dirname, "../../../", "./client"); 18 | this.jspmPath = options.jspmPath || path.join(__dirname, "../../../"); 19 | this.jspmPackagesPath = this.jspmPackagesPath || path.join(this.jspmPath, "./jspm_packages"); 20 | this.jspmConfigPath = this.jspmConfigPath || path.join(this.jspmPath, "./jspm.config.js"); 21 | this.stationOptions = options.stationOptions || {}; 22 | this.station = options.station || new Station(this.stationOptions); 23 | 24 | this.webRouter = new express.Router(); 25 | this.webRouter.use(express.static(fixWindowsPath(this.staticFolderPath))); 26 | this.webRouter.use("/jspm_packages", express.static(fixWindowsPath(this.jspmPackagesPath))); 27 | this.app.use(fixWindowsPath(path.join("/", this.rootPath)), this.webRouter); 28 | 29 | this.jspmRouter = new express.Router(); 30 | this.jspmRouter.use(express.static(fixWindowsPath(this.jspmPackagesPath))); 31 | this.app.use("/jspm_packages", this.jspmRouter); 32 | this.app.get("/jspm.config.js", (req, res) => res.sendFile(fixWindowsPath(this.jspmConfigPath))); 33 | 34 | this.webSocketClients = []; 35 | // TODO: allow for socket.io 36 | this.io = SocketIOServer(this.socket, { 37 | path: fixWindowsPath(path.join("/", this.rootPath, "/sockets")) 38 | }).on("connection", (clientSocket) => { 39 | this.webSocketClients.push(clientSocket); 40 | this.emit("webSocketClientConnect", clientSocket); 41 | 42 | clientSocket.once("disconnect", () => { 43 | this.webSocketClients.splice(this.webSocketClients.indexOf(clientSocket), 1); 44 | this.emit("webSocketClientDisconnect", clientSocket); 45 | }).on("fetch", () => { 46 | clientSocket.emit("info", { 47 | item: this.station.item, 48 | metadata: this.station.metadata, 49 | playlists: this.station.playlists, 50 | playerSourcePath: this.playerSourcePath 51 | }); 52 | }).on("next", () => { 53 | this.station.replaceNext(); 54 | }).on("addItem", (item) => { 55 | // TODO: item validation 56 | this.station.addItem(item); 57 | }).on("addPlaylist", () => { 58 | this.station.addPlaylist(); 59 | }).on("playItem", (id, playlistId) => { 60 | this.station.replacePlaylistByPlaylistIdAndItemId(playlistId, id); 61 | }).on("playPlaylist", (playlistId) => { 62 | this.station.replacePlaylistByPlaylistId(playlistId); 63 | }).on("removeItem", (id, playlistId) => { 64 | this.station.removeItem(id, playlistId); 65 | }).on("removePlaylist", (playlistId) => { 66 | this.station.removePlaylist(playlistId); 67 | }); 68 | }); 69 | 70 | this.station.on("play", (item, metadata) => { 71 | this.webSocketClients.forEach((clientSocket) => { 72 | clientSocket.emit("playing", item, metadata); 73 | }); 74 | }).on("playlistCreated", (playlist) => { 75 | this.webSocketClients.forEach((clientSocket) => { 76 | clientSocket.emit("playlistCreated", playlist); 77 | }); 78 | }).on("itemCreated", (item, playlist) => { 79 | this.webSocketClients.forEach((clientSocket) => { 80 | clientSocket.emit("itemCreated", item, playlist._id); 81 | }); 82 | }).on("itemRemoved", (item, playlist) => { 83 | this.webSocketClients.forEach((clientSocket) => { 84 | clientSocket.emit("itemRemoved", item._id, playlist._id); 85 | }); 86 | }).on("playlistRemoved", (playlist) => { 87 | this.webSocketClients.forEach((clientSocket) => { 88 | clientSocket.emit("playlistRemoved", playlist._id); 89 | }); 90 | }); 91 | } 92 | } 93 | 94 | function fixWindowsPath(url) { 95 | return url.replace(/\\/g, "/"); 96 | } 97 | -------------------------------------------------------------------------------- /src/station/index.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import ffmpeg from "fluent-ffmpeg"; 3 | import Stream from "../stream"; 4 | import Storage from "../storage"; 5 | import Playlist from "../playlist"; 6 | import Metadata from "./metadata"; 7 | import destroy from "destroy"; 8 | 9 | export default class Station extends EventEmitter { 10 | constructor(options) { 11 | super(); 12 | 13 | options = options || {}; 14 | this.playlists = options.playlists || []; 15 | this.bufferSize = options.bufferSize || null; 16 | this.dataInterval = options.dataInterval || null; 17 | this.prebufferSize = options.prebufferSize || null; 18 | this.postProcessingBitRate = options.postProcessingBitRate || 128; 19 | this.storageType = options.storageType || "JSON"; 20 | this.ffmpegPath = options.ffmpegPath || null; 21 | 22 | this.ffmpegPath && ffmpeg.setFfmpegPath(this.ffmpegPath); 23 | this.storage = new Storage(this.storageType); 24 | 25 | this.itemId = null; 26 | this.item = null; 27 | this.metadata = null; 28 | 29 | this.stream = new Stream({ 30 | bufferSize: this.bufferSize, 31 | dataInterval: this.dataInterval, 32 | prebufferSize: this.prebufferSize, 33 | needMoreData: this.streamNeedsMoreData.bind(this) 34 | }); 35 | this.stream.once("data", () => this.emit("start")); 36 | this.stream.on("data", (data, metadata, item) => { 37 | if (this.itemId !== item._id) { 38 | this.itemId = item._id; 39 | this.item = item; 40 | this.metadata = metadata; 41 | this.emit("play", item, metadata); 42 | } 43 | 44 | this.emit("data", data, metadata, item); 45 | }); 46 | 47 | this.playlistPlay = this.playlistPlay.bind(this); 48 | this.playlistReplace = this.playlistReplace.bind(this); 49 | 50 | this.playlists = this.playlists.map((items) => new Playlist({ 51 | items: items 52 | })); 53 | } 54 | 55 | start() { 56 | this.storage.activate({}, (err) => { 57 | if (err) return console.log(err); 58 | this.storage.fill(this.playlists, () => { 59 | this.storage.findAll((err, playlists) => { 60 | if (err) return console.log(err); 61 | this.playlists = playlists; 62 | 63 | this.stream.start(); 64 | }); 65 | }); 66 | }); 67 | } 68 | 69 | addPlaylist(playlist) { 70 | playlist = this.preparePlaylist(playlist); 71 | this.storage.insert(playlist, (err) => { 72 | if (err) return console.log(err); 73 | 74 | this.playlists.push(playlist); 75 | 76 | this.emit("playlistCreated", playlist); 77 | 78 | if (!this.playlist) { 79 | this.handleNoPlaylist(); 80 | } 81 | }); 82 | } 83 | 84 | addItem(item) { 85 | const playlist = this.playlist; 86 | if (playlist) { 87 | const wasPlaylistEmpty = playlist.items.length < 1; 88 | item = playlist.addItem(item); 89 | 90 | this.storage.update(playlist, (err) => { 91 | // TODO: remove item if err 92 | if (err) return console.log(err); 93 | 94 | this.emit("itemCreated", item, playlist); 95 | 96 | if (wasPlaylistEmpty) { 97 | this.playNext(); 98 | } 99 | }); 100 | } else { 101 | // TODO: create playlist with item in it 102 | console.log("NYI"); 103 | } 104 | } 105 | 106 | removeItem(id, playlistId) { 107 | const playlist = this.findPlaylistById(playlistId); 108 | if (playlist) { 109 | const removed = playlist.removeItem(id); 110 | const itemIndex = playlist.items.indexOf(removed); 111 | if (removed) { 112 | this.storage.update(playlist, (err) => { 113 | if (err) { 114 | playlist.items.splice(itemIndex, 0, removed); 115 | return console.error(err); 116 | } 117 | 118 | this.emit("itemRemoved", removed, playlist); 119 | 120 | if (removed._id === this.itemId) { 121 | this.replaceNext(); 122 | } 123 | }); 124 | } else { 125 | console.log("item to remove not found"); 126 | } 127 | } else { 128 | console.log("playlist not found"); 129 | } 130 | } 131 | 132 | removePlaylist(playlistId) { 133 | const playlist = this.findPlaylistById(playlistId); 134 | if (playlist) { 135 | const playlistIndex = this.playlists.indexOf(playlist); 136 | this.playlists.splice(playlistIndex, 1); 137 | this.storage.remove(playlist._id, (err) => { 138 | if (err) { 139 | this.playlists.splice(playlistIndex, 0, playlist); 140 | return console.error(err); 141 | } 142 | 143 | this.emit("playlistRemoved", playlist); 144 | 145 | if (playlist._id === this.playlist._id) { 146 | this.playlist = null; 147 | this.replaceNext(); 148 | } 149 | }); 150 | } else { 151 | console.log("playlist to remove not found"); 152 | } 153 | } 154 | 155 | preparePlaylist(playlist) { 156 | playlist = playlist || []; 157 | if (Array.isArray(playlist)) { 158 | return new Playlist(playlist); 159 | } else { 160 | return playlist; 161 | } 162 | } 163 | 164 | replacePlaylistByPlaylistId(playlistId) { 165 | const playlist = this.findPlaylistById(playlistId); 166 | if (playlist) this.replacePlaylist(playlist); 167 | } 168 | 169 | replacePlaylistByPlaylistIdAndItemId(playlistId, itemId) { 170 | const playlist = this.findPlaylistById(playlistId); 171 | if (playlist) { 172 | this.replacePlaylistAndItemId(playlist, itemId); 173 | } 174 | } 175 | 176 | replacePlaylist(playlist) { 177 | this.changePlaylist(playlist); 178 | this.replaceNext(); 179 | } 180 | 181 | replacePlaylistAndItemId(playlist, itemId) { 182 | this.changePlaylist(playlist); 183 | this.replaceItemId(itemId); 184 | } 185 | 186 | changePlaylist(playlist) { 187 | if (this.playlist && this.playlist._id === playlist._id) return; 188 | 189 | if (this.playlist) { 190 | this.playlist.removeListener("play", this.playlistPlay); 191 | this.playlist.removeListener("replace", this.playlistReplace); 192 | } 193 | this.playlist = playlist; 194 | this.playlist.on("play", this.playlistPlay); 195 | this.playlist.on("replace", this.playlistReplace); 196 | } 197 | 198 | findPlaylistById(id) { 199 | return this.playlists.find((playlist) => { 200 | return playlist._id === id; 201 | }); 202 | } 203 | 204 | playNext() { 205 | if (this.playlist) { 206 | this.handleNothingToPlay(!this.playlist.playNext()); 207 | } else { 208 | this.handleNoPlaylist(); 209 | } 210 | } 211 | 212 | replaceNext() { 213 | if (this.playlist) { 214 | this.handleNothingToPlay(!this.playlist.replaceNext()); 215 | } else { 216 | this.handleNoPlaylist(); 217 | } 218 | } 219 | 220 | replaceItemId(itemId) { 221 | if (this.playlist) { 222 | const canPlay = !this.playlist.replaceItemByItemId(itemId); 223 | if (!canPlay) { 224 | this.replaceNext(); 225 | } 226 | } else { 227 | this.handleNoPlaylist(); 228 | } 229 | } 230 | 231 | handleNothingToPlay(isPlaylistEmpty) { 232 | if (isPlaylistEmpty) { 233 | this.emit("nothingToPlay", this.playlist); 234 | } 235 | } 236 | 237 | handleNoPlaylist() { 238 | if (this.playlists.length > 0) { 239 | this.replacePlaylist(this.playlists[0]); 240 | } else { 241 | this.emit("nothingToPlay", this.playlist); 242 | } 243 | } 244 | 245 | streamNeedsMoreData() { 246 | this.playNext(); 247 | } 248 | 249 | playlistPlay(err, stream, metadata, item, options) { 250 | if (err) return this.onPlayError(err); 251 | options = options || {}; 252 | 253 | this.handleStreamError(stream); 254 | 255 | stream = this.handlePostProcessing(stream, options); 256 | metadata = new Metadata(metadata); 257 | this.stream.next(stream, metadata, item); 258 | } 259 | 260 | playlistReplace(err, stream, metadata, item, options) { 261 | if (err) return this.onPlayError(err); 262 | options = options || {}; 263 | 264 | this.handleStreamError(stream); 265 | 266 | stream = this.handlePostProcessing(stream, options); 267 | metadata = new Metadata(metadata); 268 | this.stream.replace(stream, metadata, item); 269 | } 270 | 271 | onPlayError(err) { 272 | this.emit("error", err); 273 | console.log("trying to play next item..."); 274 | this.playNext(); 275 | } 276 | 277 | handleStreamError(stream) { 278 | return stream.once("error", (err) => { 279 | destroy(stream); 280 | this.onPlayError(err); 281 | }); 282 | } 283 | 284 | handlePostProcessing(stream, options) { 285 | options = options || {}; 286 | 287 | if (options.streamNeedsPostProcessing) { 288 | stream = ffmpeg(stream) 289 | .audioBitrate(this.postProcessingBitRate) 290 | .format("mp3"); 291 | } 292 | 293 | return this.handleStreamError(stream); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/station/metadata.js: -------------------------------------------------------------------------------- 1 | const METADATA_BLOCK_SIZE = 16; 2 | const METADATA_LIMIT = METADATA_BLOCK_SIZE * 255; 3 | 4 | export default class Metadata { 5 | constructor(options) { 6 | this.options = options; 7 | this.text = this.formatText(); 8 | if (this.text.length > METADATA_LIMIT) throw new Error("metadata text length is more than " + METADATA_LIMIT); 9 | 10 | this.chars = Metadata.ensureBlockSize(this.textToBytes()); 11 | this.length = this.chars.length / METADATA_BLOCK_SIZE; 12 | } 13 | 14 | formatText() { 15 | const keys = Object.keys(this.options); 16 | return keys.map((key) => { 17 | return key + "='" + this.options[key] + "'"; 18 | }).join(",") + ";"; 19 | } 20 | 21 | textToBytes() { 22 | return this.text.split("").map(function (x) { 23 | return x.charCodeAt(0); 24 | }); 25 | } 26 | 27 | createCombinedBuffer(buffer) { 28 | return Buffer.concat([buffer, new Buffer([this.length]), new Buffer(this.chars)]); 29 | } 30 | 31 | toJSON() { 32 | return this.options; 33 | } 34 | 35 | static ensureBlockSize(chars) { 36 | const rest = METADATA_BLOCK_SIZE - (chars.length % METADATA_BLOCK_SIZE); 37 | if (rest < METADATA_BLOCK_SIZE) { 38 | for (let i = 0; i < rest; i++) { 39 | chars.push(0); 40 | } 41 | } 42 | return chars; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/storage/index.js: -------------------------------------------------------------------------------- 1 | import Playlist from "../playlist"; 2 | import JSONType from "./types/json"; 3 | import MemoryType from "./types/memory"; 4 | 5 | const typePluginMap = {}; 6 | 7 | export default class Storage { 8 | constructor(type) { 9 | const typePlugin = Storage.getType(type); 10 | if (!typePlugin) throw new Error("Unknown storage type"); 11 | this.type = type; 12 | this.typePlugin = typePlugin; 13 | } 14 | 15 | activate(options, done) { 16 | this.typePlugin.activate(options, done); 17 | } 18 | 19 | fill(playlists, done) { 20 | if (!playlists) return done(); 21 | if (playlists.length <= 0) return done(); 22 | if (!this.typePlugin.isFillable) { 23 | console.warn("storage type is not fillable"); 24 | return done(); 25 | } 26 | 27 | playlists = mapPlaylists(playlists); 28 | this.typePlugin.fill(playlists, done); 29 | } 30 | 31 | findAll(done) { 32 | this.typePlugin.findAll(mapObjects(done)); 33 | } 34 | 35 | insert(playlist, done) { 36 | this.typePlugin.insert(playlist, done); 37 | } 38 | 39 | update(playlist, done) { 40 | this.typePlugin.update(playlist, done); 41 | } 42 | 43 | remove(id, done) { 44 | this.typePlugin.remove(id, done); 45 | } 46 | 47 | static registerType(type, typePlugin) { 48 | typePluginMap[type] = typePlugin; 49 | } 50 | 51 | static getType(type) { 52 | return typePluginMap[type]; 53 | } 54 | 55 | static getTypeNames() { 56 | return Object.keys(typePluginMap); 57 | } 58 | } 59 | 60 | Storage.registerType("JSON", new JSONType()); 61 | Storage.registerType("Memory", new MemoryType()); 62 | 63 | function mapObjects(done) { 64 | return function (err, playlists) { 65 | if (err) return done(err); 66 | if (!Array.isArray(playlists)) return done(null, new Playlist(playlists)); 67 | 68 | done(null, playlists.map((playlist) => new Playlist(playlist))); 69 | }; 70 | } 71 | 72 | function mapPlaylists(playlists) { 73 | if (!Array.isArray(playlists)) return mapPlaylist(playlists); 74 | 75 | return playlists.map((playlist) => mapPlaylist(playlist)); 76 | } 77 | 78 | function mapPlaylist(playlist) { 79 | return playlist.toJSON ? playlist.toJSON() : playlist; 80 | } 81 | -------------------------------------------------------------------------------- /src/storage/types/json.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import mkdirp from "mkdirp"; 4 | 5 | export default class JSONType { 6 | constructor() {} 7 | 8 | activate(options, done) { 9 | this.folder = options.folder || "./json"; 10 | mkdirp(this.folder, done); 11 | } 12 | 13 | findAll(done) { 14 | fs.readdir(this.folder, (err, files) => { 15 | const playlists = files.map((playlistFile) => { 16 | const filename = path.join(this.folder, playlistFile); 17 | const data = fs.readFileSync(filename).toString(); 18 | return JSON.parse(data); 19 | }); 20 | done && done(null, playlists); 21 | }); 22 | } 23 | 24 | insert(playlist, done) { 25 | const jsonString = JSON.stringify(playlist, null, 2); 26 | const filename = path.join(this.folder, playlist._id + ".json"); 27 | fs.writeFile(filename, jsonString, (err) => { 28 | if (err) return done && done(err); 29 | done && done(); 30 | }); 31 | } 32 | 33 | update(playlist, done) { 34 | this.insert(playlist, done); 35 | } 36 | 37 | remove(id, done) { 38 | const filename = path.join(this.folder, id + ".json"); 39 | fs.unlink(filename, (err) => { 40 | if (err) return done && done(err); 41 | done && done(); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/storage/types/memory.js: -------------------------------------------------------------------------------- 1 | export default class MemoryType { 2 | constructor() { 3 | this.isFillable = true; 4 | } 5 | 6 | activate(options, done) { 7 | this.playlistIdMap = {}; 8 | done && done(); 9 | } 10 | 11 | fill(playlists, done) { 12 | playlists.forEach((playlist) => this.insert(playlist)); 13 | done && done(); 14 | } 15 | 16 | findAll(done) { 17 | const playlists = Object.keys(this.playlistIdMap).map((playlistId) => this.playlistIdMap[playlistId]); 18 | done && done(null, playlists); 19 | } 20 | 21 | insert(playlist, done) { 22 | this.playlistIdMap[playlist._id] = playlist; 23 | done && done(); 24 | } 25 | 26 | update(playlist, done) { 27 | this.playlistIdMap[playlist._id] = playlist; 28 | done && done(); 29 | } 30 | 31 | remove(id, done) { 32 | delete this.playlistIdMap[id]; 33 | done && done(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/stream/index.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import StreamInfo from "./info"; 3 | 4 | export default class Stream extends EventEmitter { 5 | constructor(options) { 6 | super(); 7 | 8 | options = options || {}; 9 | this.bufferSize = options.bufferSize || 8192; 10 | this.prebufferSize = options.prebufferSize || this.bufferSize * 20; 11 | this.dataInterval = options.dataInterval || 500; 12 | this.needMoreData = options.needMoreData || function () {}; 13 | this.streamInfos = []; 14 | } 15 | 16 | start() { 17 | this.dataLoop = this.dataLoop.bind(this); 18 | this.dataLoop(); 19 | } 20 | 21 | dataLoop() { 22 | this.checkNextStream(); 23 | 24 | const streamInfo = this.streamInfos[0]; 25 | if (streamInfo && streamInfo.isHeaderPresent) { 26 | const bufferAmountToSend = streamInfo.calculateBufferAmountToSend(this.bufferSize); 27 | for (let i = 0; i < bufferAmountToSend; i++) { 28 | const previousBuffer = this.previousBuffer || new Buffer([]); 29 | const bufferToSend = this.collectRealtimeBuffer(previousBuffer, this.bufferSize); 30 | if (bufferToSend) { 31 | if (bufferToSend.length >= this.bufferSize) { 32 | this.previousBuffer = null; 33 | this.emit("data", bufferToSend, streamInfo.metadata, streamInfo.item); 34 | } else { 35 | this.previousBuffer = bufferToSend; 36 | console.log("buffer to small"); 37 | } 38 | } else { 39 | console.log("no buffer"); 40 | } 41 | } 42 | } 43 | 44 | setTimeout(this.dataLoop, this.dataInterval); 45 | } 46 | 47 | collectRealtimeBuffer(realtimeBuffer) { 48 | const streamInfo = this.streamInfos[0]; 49 | if (realtimeBuffer.length >= this.bufferSize) return realtimeBuffer; 50 | if (!streamInfo) return realtimeBuffer; 51 | 52 | const missingBytes = this.bufferSize - realtimeBuffer.length; 53 | realtimeBuffer = Buffer.concat([realtimeBuffer, streamInfo.cutBeginOfBuffer(missingBytes)]); 54 | 55 | streamInfo.processedBytes += realtimeBuffer.length; 56 | 57 | if (realtimeBuffer.length < this.bufferSize) { 58 | const streamInfo = this.streamInfos.shift(); 59 | streamInfo.destroy(); 60 | return this.collectRealtimeBuffer(realtimeBuffer); 61 | } 62 | 63 | return realtimeBuffer; 64 | } 65 | 66 | getRealtimeBufferSize() { 67 | return this.streamInfos 68 | .map(streamInfo => streamInfo.buffer.length) 69 | .reduce((previous, length) => { 70 | return previous + length; 71 | }, 0); 72 | } 73 | 74 | next(stream, metadata, item) { 75 | this.streamInfos.push(new StreamInfo(stream, metadata, item)); 76 | this.checkNextStream(); 77 | } 78 | 79 | replace(stream, metadata, item) { 80 | this.streamInfos.forEach((streamInfo) => streamInfo.destroy()); 81 | this.streamInfos = [new StreamInfo(stream, metadata, item)]; 82 | this.didAlreadyRequest = true; 83 | this.checkNextStream(); 84 | } 85 | 86 | checkNextStream() { 87 | if (!this.isCollectingData() && this.getRealtimeBufferSize() <= this.prebufferSize) { 88 | const streamInfo = this.getCollectableStreamInfo(); 89 | if (streamInfo) { 90 | streamInfo.once("collected", () => { 91 | this.didAlreadyRequest = false; 92 | }); 93 | streamInfo.collect(); 94 | } else { 95 | if (!this.didAlreadyRequest) { 96 | this.didAlreadyRequest = true; 97 | this.needMoreData(); 98 | } 99 | } 100 | } 101 | } 102 | 103 | getCollectableStreamInfo() { 104 | return this.streamInfos.find((streamInfo) => { 105 | return !streamInfo.isCollected; 106 | }); 107 | } 108 | 109 | isCollectingData() { 110 | return this.streamInfos.some((streamInfo) => { 111 | return streamInfo.isCollecting; 112 | }); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/stream/info.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { MP3 as Mp3Parser } from "sm-parsers"; 3 | import destroy from "destroy"; 4 | 5 | export default class StreamInfo extends EventEmitter { 6 | constructor(stream, metadata, item) { 7 | super(); 8 | 9 | this.stream = stream; 10 | this.metadata = metadata; 11 | this.item = item; 12 | 13 | this.isCollected = false; 14 | this.isCollecting = false; 15 | this.buffer = new Buffer([]); 16 | this.parser = new Mp3Parser(); 17 | this.processedBytes = 0; 18 | 19 | this.stream.once("end", () => { 20 | this.isCollected = true; 21 | this.isCollecting = false; 22 | 23 | this.emit("collected", this); 24 | }); 25 | 26 | this.parser 27 | .once("frame", (data, firstHeader) => { 28 | this.setHeader(firstHeader); 29 | }).on("frame", (data) => { 30 | this.appendToBuffer(data); 31 | }); 32 | } 33 | 34 | collect() { 35 | this.isCollecting = true; 36 | this.stream.pipe(this.parser); 37 | } 38 | 39 | setHeader(firstHeader) { 40 | this.isHeaderPresent = true; 41 | this.RTC = new Date(); 42 | this.bytesPerMillisecond = firstHeader.frames_per_sec * firstHeader.frameSizeRaw / 1000; 43 | } 44 | 45 | appendToBuffer(data) { 46 | this.buffer = Buffer.concat([this.buffer, data]); 47 | } 48 | 49 | cutBeginOfBuffer(length) { 50 | const removedBuffer = this.buffer.slice(0, length); 51 | this.buffer = this.buffer.slice(length, this.buffer.length); 52 | return removedBuffer; 53 | } 54 | 55 | calculateBufferAmountToSend(bufferSize) { 56 | const millisecondsSinceStart = new Date() - this.RTC; 57 | const bytesSinceStart = millisecondsSinceStart * this.bytesPerMillisecond; 58 | const buffersSinceStart = Math.floor(bytesSinceStart / bufferSize); 59 | const processedBuffers = Math.floor(this.processedBytes / bufferSize); 60 | return buffersSinceStart - processedBuffers; 61 | } 62 | 63 | destroy() { 64 | destroy(this.stream); 65 | this.stream.removeAllListeners(); 66 | this.stream = null; 67 | 68 | destroy(this.parser); 69 | this.parser.removeAllListeners(); 70 | this.parser = null; 71 | } 72 | } 73 | --------------------------------------------------------------------------------