├── .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 | 
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------