├── .gitignore
├── .prettierrc
├── README.md
├── docz
├── assets
│ └── CLIUsage.png
└── index.html
├── doczrc.js
├── package-lock.json
├── package.json
└── src
├── api
├── auth.js
├── config.js
├── endpoints.mdx
├── frontendDevelopment.mdx
├── index.js
├── library.js
└── stream.js
├── frontendsAndNotableProjects.mdx
├── generate
├── FAQ.mdx
├── config.mdx
├── generate.js
├── template
│ ├── audio
│ │ ├── Aviscerall - Just Livin' - 05 Unhappy.mp3
│ │ ├── Aviscerall - Lp's and Love Songs - 01 I Am a Fool for Beauty.mp3
│ │ ├── Aviscerall - Nostalgia Infinite - 04 Scattered Dreams.mp3
│ │ ├── Miata feat Of The Other Time.mp3
│ │ └── nestedDirectoriesWork
│ │ │ ├── Aviscerall - Cruisin' - 01 Sanctuary.mp3
│ │ │ └── Aviscerall - Just Livin' - 07 Ascension feat. Groovy Godzilla.mp3
│ ├── config.json
│ ├── fonts
│ │ └── Lato-Regular.ttf
│ ├── interludes
│ │ ├── audio
│ │ │ ├── nestedDirectoriesWork
│ │ │ │ └── tapeDeckSound.mp3
│ │ │ └── tapeDeckSound.mp3
│ │ └── video
│ │ │ └── example 1.gif
│ ├── live-stream-radio-overlay-image.png
│ └── video
│ │ ├── nestedDirectoriesWork
│ │ └── publicDomainEarth.webm
│ │ └── publicDomainEarth.mp4
└── tips.mdx
├── history.service.js
├── index.js
├── index.mdx
├── raspberryPi.mdx
├── stream
├── gettingstarted.mdx
├── gif.js
├── index.js
├── overlayText.js
├── randomFile.js
├── safeStrings.js
├── stream.js
└── usage.mdx
└── supportedFileTypes.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # System Files
2 | **/.DS_Store
3 |
4 | # Generator testing
5 | **/myStream/**/*
6 | **/live-stream-radio/**/*
7 |
8 | # Stream Testing to a file
9 | **/*.flv
10 |
11 | # docz
12 | **/.docz/**/*
13 | **/.docz/dist/**/*
14 | **/docz/dist/**/*
15 |
16 | # Logs
17 | logs
18 | *.log
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | # Runtime data
24 | pids
25 | *.pid
26 | *.seed
27 | *.pid.lock
28 |
29 | # Directory for instrumented libs generated by jscoverage/JSCover
30 | lib-cov
31 |
32 | # Coverage directory used by tools like istanbul
33 | coverage
34 |
35 | # nyc test coverage
36 | .nyc_output
37 |
38 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
39 | .grunt
40 |
41 | # Bower dependency directory (https://bower.io/)
42 | bower_components
43 |
44 | # node-waf configuration
45 | .lock-wscript
46 |
47 | # Compiled binary addons (https://nodejs.org/api/addons.html)
48 | build/Release
49 |
50 | # Dependency directories
51 | node_modules/
52 | jspm_packages/
53 |
54 | # TypeScript v1 declaration files
55 | typings/
56 |
57 | # Optional npm cache directory
58 | .npm
59 |
60 | # Optional eslint cache
61 | .eslintcache
62 |
63 | # Optional REPL history
64 | .node_repl_history
65 |
66 | # Output of 'npm pack'
67 | *.tgz
68 |
69 | # Yarn Integrity file
70 | .yarn-integrity
71 |
72 | # dotenv environment variables file
73 | .env
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # next.js build output
79 | .next
80 |
81 | # nuxt.js build output
82 | .nuxt
83 |
84 | # vuepress build output
85 | .vuepress/dist
86 |
87 | # Serverless directories
88 | .serverless
89 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 140,
3 | "singleQuote": true,
4 | "useTabs": false,
5 | "tabWidth": 2,
6 | "semi": true,
7 | "bracketSpacing": true
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # live-stream-radio
2 |
3 | _formerly known as piStreamRadio._
4 |
5 | 
6 |
7 | [CLI Usage Screenshot](./docz/assets/CLIUsage.png) 🖼️
8 |
9 | [Documentation](https://torch2424.github.io/live-stream-radio/) 📚
10 |
11 | `live-stream-radio` is a 24/7 live stream video radio station 📹 📻 CLI built with [Node.js](https://nodejs.org/) and powered by [FFmpeg](http://ffmpeg.org). Meaning, This will allow for live streaming a video of music, playing over a video/gif, with the music information, and other overlay items 🖼️. Music and video are chosen from their respective folders in a defined `config.json` that can be generated using the CLI. Generated projects come included with some songs and videos to get up and running quickly! Also, this project has a REST HTTP JSON Api, to allow for interfacing with your stream using a frontend 👩💻.
12 |
13 | # Table of Contents
14 |
15 | - [Getting Started](#getting-started)
16 | - [API Frontends](#api-frontends)
17 | - [Compatibility](#compatibility)
18 | - [Example Assets from the `--generate` template](#example-assets-from-the---generate-template)
19 | - [Contributing](#contributing)
20 | - [License](#license)
21 |
22 | # Getting Started
23 |
24 | Please see the [Documentation](https://torch2424.github.io/live-stream-radio/) 📚 for how to get started using `live-stream-radio`. In particular, the [Installation Guide](https://torch2424.github.io/live-stream-radio/#/cli/getting-started#installation) and [CLI Usage](https://torch2424.github.io/live-stream-radio/#/cli/usage) will be the most useful to new users. 😄
25 |
26 | # API Frontends
27 |
28 | _For building your own API frontend, please see the [API Documentation](https://torch2424.github.io/live-stream-radio/#/api/endpoints) 📚 on API Endpoints._
29 |
30 | Currently, there are no supported API frontends. However, Contributions are welcome! If you make a `live-stream-radio` frontend, please open an issue and so we can add the project here 😄!
31 |
32 | # Other Notable Projects
33 |
34 | - [live-stream-radio control](https://github.com/BaileyMcKelway/live-stream-radio-api-frontend) - Web control for `live-stream-radio` for streams on Twitch.
35 | - [lsr-wrapper](https://github.com/LSRemote/lsr-wrapper) - A Promise based wrapper around the `live-stream-radio` api.
36 | - [live-stream-radio-cp](https://github.com/Tresmos/live-stream-radio-cp) - Simple web control panel for live-stream-radio.
37 |
38 | # Radios built with `live-stream-radio`
39 |
40 | Please feel free to share your radio if you are using `live-stream-radio`. Just open an issue, and we can add it to the README. 😄
41 |
42 | # Compatibility
43 |
44 | Currently, this should work under any OS with support for [Node](https://nodejs.org/en/) and [FFMPEG](https://www.ffmpeg.org/). Specifically in the tradition of this project being developed for Raspberry Pi, formerly as piStreamRadio , this also supports Raspbian as well.
45 |
46 | # Example Assets from the `--generate` template
47 |
48 | Music is by [Aviscerall](https://aviscerall.bandcamp.com/), and [Marquice Turner](https://marquiceturner.bandcamp.com/). Which is actually me (@torch2424), but I have a musical identitiy problem 😛 . The .mp4 and .webm of the rotating earth, is a [public domain video I found on Youtube](https://www.youtube.com/watch?v=uuY1RXZyUFs). The image overlay uses images from EmojiOne, in particular, their [video camera emoji](https://www.emojione.com/emoji/1f4f9), and their [radio emoji](https://www.emojione.com/emoji/1f4fb).
49 |
50 | # Contributing
51 |
52 | Feel free to fork the project, open up a PR, and give any contributions! I'd suggest opening an issue first however, just so everyone is aware and can discuss the proposed changes. 👍
53 |
54 | # License
55 |
56 | LICENSE under [Apache 2.0](https://choosealicense.com/licenses/apache-2.0/). 🐦
57 |
58 | This software uses code of [FFmpeg](http://ffmpeg.org) licensed under the [LGPLv2.1](http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html) and it's source can be downloaded [here](./deps/ffmpeg).
59 |
60 | As such, this software tries to respect the LGPLv2 License as close as possible to respect FFmpeg and it's authors. Huge shoutout to them for building such an awesome and crazy tool!
61 |
--------------------------------------------------------------------------------
/docz/assets/CLIUsage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/docz/assets/CLIUsage.png
--------------------------------------------------------------------------------
/docz/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {{ title }}
10 | {{head}}
11 |
12 |
13 |
14 |
15 | {{footer}}
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/doczrc.js:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import externalLinks from 'remark-external-links';
3 |
4 | const Public = path.resolve(__dirname, 'public');
5 | const Src = path.resolve(__dirname, 'src');
6 |
7 | export default {
8 | src: './src',
9 | base: '/live-stream-radio/',
10 | title: 'live-stream-radio',
11 | description: '24/7 live stream video radio station CLI / API 📹 📻',
12 | ordering: 'ascending',
13 | propsParser: false,
14 | indexHtml: 'docz/index.html',
15 | htmlContext: {
16 | favicon: '/docz/favicon.ico'
17 | },
18 | hashRouter: true,
19 | mdPlugins: [externalLinks.default],
20 | plugins: [],
21 | modifyBundlerConfig: config => {
22 | config.resolve.alias = {
23 | ...config.resolve.alias,
24 | '@fonts': `${Public}/fonts`,
25 | '@images': `${Public}/images`,
26 | '@components': `${Src}/theme/components`,
27 | '@styles': `${Src}/theme/styles`
28 | };
29 | return config;
30 | }
31 | };
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "live-stream-radio",
3 | "version": "2.2.1",
4 | "description": "24/7 live stream video radio station CLI / API",
5 | "main": "index.js",
6 | "bin": {
7 | "live-stream-radio": "./src/index.js"
8 | },
9 | "scripts": {
10 | "precommit": "pretty-quick --staged",
11 | "start": "node src/index.js live-stream-radio",
12 | "generate": "rm -rf live-stream-radio && node src/index.js --generate",
13 | "dev": "node src/index.js live-stream-radio --output dev.flv",
14 | "prettier": "npm run prettier:fix",
15 | "lint": " echo \"Listing unlinted files, will show nothing if everything is fine.\" && npx prettier --config .prettierrc --list-different src/**/*.js",
16 | "lint:fix": "npx prettier --config .prettierrc --write src/**/*.js",
17 | "deploy": "npx np",
18 | "docz:dev": "docz dev",
19 | "docz:build": "docz build",
20 | "docz:deploy": "npm run docz:build && gh-pages -d .docz/dist"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "git+https://github.com/torch2424/live-stream-radio.git"
25 | },
26 | "author": "Aaron Turner",
27 | "license": "Apache-2.0",
28 | "bugs": {
29 | "url": "https://github.com/torch2424/live-stream-radio/issues"
30 | },
31 | "homepage": "https://github.com/torch2424/live-stream-radio#readme",
32 | "dependencies": {
33 | "chalk": "^2.4.1",
34 | "chalkline": "0.0.5",
35 | "cli-progress": "^2.1.0",
36 | "edit-json-file": "^1.1.0",
37 | "fastify": "^1.12.1",
38 | "fastify-formbody": "^2.0.1",
39 | "find": "^0.2.9",
40 | "fluent-ffmpeg": "^2.1.2",
41 | "fs-extra": "^7.0.0",
42 | "imagemin": "^6.0.0",
43 | "imagemin-gifsicle": "^5.2.0",
44 | "is-running": "^2.1.0",
45 | "minimist": "^1.2.0",
46 | "music-metadata": "^3.1.0",
47 | "term-img": "^2.1.0",
48 | "upath": "^1.1.0"
49 | },
50 | "devDependencies": {
51 | "docz": "^0.11.2",
52 | "gh-pages": "^2.0.0",
53 | "husky": "^1.0.1",
54 | "np": "^3.0.4",
55 | "prettier": "1.14.3",
56 | "pretty-quick": "^1.7.0",
57 | "remark-external-links": "^3.0.0"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/api/auth.js:
--------------------------------------------------------------------------------
1 | // Function to verify a key
2 | const verifyKey = async (getConfig, request) => {
3 | // Get our returned config
4 | config = await getConfig();
5 |
6 | if (!config.api.key) {
7 | return true;
8 | }
9 |
10 | // Array of places to store the API key
11 | const supportedApiKeyFields = [request.headers.authorization, request.query.api_key];
12 |
13 | // Also check POST request bodies
14 | if (request.body) {
15 | supportedApiKeyFields.push(request.body.api_key);
16 | }
17 |
18 | return supportedApiKeyFields.some(keyField => {
19 | return config.api.key === keyField;
20 | });
21 | };
22 |
23 | // Function to wrap a standard route handler
24 | const secureRouteHandler = (getConfig, routeHandler) => {
25 | return async (request, reply) => {
26 | const keyResponse = await verifyKey(getConfig, request);
27 | if (keyResponse) {
28 | return await routeHandler(request, reply);
29 | } else {
30 | reply.type('application/json').code(401);
31 | return {
32 | message: 'Unauthorized: Please pass a valid API Key'
33 | };
34 | }
35 | };
36 | };
37 |
38 | // File to handle api authentication
39 | module.exports = {
40 | verifyKey: verifyKey,
41 | secureRouteHandler: secureRouteHandler
42 | };
43 |
--------------------------------------------------------------------------------
/src/api/config.js:
--------------------------------------------------------------------------------
1 | const authService = require('./auth');
2 | const upath = require('upath');
3 | const fs = require('fs-extra');
4 | const editJsonFile = require('edit-json-file');
5 |
6 | const getFullConfig = async path => {
7 | // Get current config
8 | let config = editJsonFile(upath.join(path, 'config.json'));
9 |
10 | // Return Config
11 | return [200, config.toObject()];
12 | };
13 |
14 | const getConfigByKey = async (path, key) => {
15 | // Get current config
16 | let configFile = editJsonFile(upath.join(path, 'config.json'));
17 |
18 | // Return Config by a specific key
19 | let configValue = configFile.get(key);
20 |
21 | // Return 200 if it has a value and 404 if it does not have a value
22 | if (configValue) {
23 | return [200, configValue];
24 | } else {
25 | return [404, null];
26 | }
27 | };
28 |
29 | const changeConfig = async (path, config, key, newValue) => {
30 | // Change config
31 | let configFile = editJsonFile(upath.join(path, 'config.json'));
32 | let currentValue = configFile.get(key);
33 |
34 | configFile.set(key, JSON.parse(newValue));
35 | configFile.save();
36 |
37 | return [200, { key: key, oldValue: currentValue, newValue: JSON.parse(newValue) }];
38 | };
39 |
40 | module.exports = (fastify, path, stream, getConfig) => {
41 | fastify.get(
42 | '/config',
43 | authService.secureRouteHandler(getConfig, async (request, reply) => {
44 | // Returns full config is "key" is not set, otherwise only return the requested key
45 | let response;
46 | if (request.query.key) {
47 | response = await getConfigByKey(path, request.query.key);
48 | } else {
49 | response = await getFullConfig(path);
50 | }
51 |
52 | reply.type('application/json').code(response[0]);
53 | return {
54 | key: request.query.key,
55 | value: response[1]
56 | };
57 | })
58 | );
59 |
60 | // Change a setting
61 | fastify.post(
62 | '/config',
63 | authService.secureRouteHandler(getConfig, async (request, reply) => {
64 | // We need our actual config here to make sure we are reurning the static json file
65 | const config = require(`${path}/config.json`);
66 | let response = await changeConfig(path, config, request.body.key, request.body.value);
67 |
68 | reply.type('application/json').code(response[0]);
69 | return {
70 | response: response[1]
71 | };
72 | })
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/src/api/endpoints.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Endpoints
3 | route: /api/endpoints
4 | menu: API
5 | ---
6 |
7 | # Endpoints
8 |
9 | The API is written as an HTTP JSON REST API. Meaning all requests are expected to be of `Content-Type: application/json`, and will return JSON. Also, will use the standard [HTTP REST status codes](https://www.restapitutorial.com/httpstatuscodes.html).
10 |
11 | All Api Endpoints require the API key within the `config.json` to make / return responses to requests.
12 |
13 | The API Key can be provided in 3 ways:
14 |
15 | * Through an `Authorization` Header.
16 |
17 | `Authorization: my-api-key`
18 |
19 | * Through the `api_key` query paramater
20 |
21 | `localhost:3000/stream?api_key=my-api-key`
22 |
23 | * Through the `api_key` POST body
24 |
25 | ```
26 | {
27 |
28 | "api_key": "my-api-key"
29 |
30 | ...
31 |
32 | }
33 | ```
34 |
35 | ## GET /stream
36 |
37 | ### Description
38 |
39 | Returns the status of the stream.
40 |
41 | ### Query Params
42 |
43 | None
44 |
45 | ### Example Successful Response:
46 |
47 | ```
48 | {
49 | "isRunning": true
50 | }
51 | ```
52 |
53 | ## POST /stream/start
54 |
55 | ### Description
56 |
57 | Starts the stream, if not already running.
58 |
59 | ### Body Params
60 |
61 | None
62 |
63 | ### Example Successful Response:
64 |
65 | ```
66 | {
67 | "message": "OK"
68 | }
69 | ```
70 |
71 | ## POST /stream/stop
72 |
73 | ### Description
74 |
75 | Stops the stream, if running.
76 |
77 | ### Body Params
78 |
79 | None
80 |
81 | ### Example Successful Response:
82 |
83 | ```
84 | {
85 | "message": "OK"
86 | }
87 | ```
88 |
89 | ## POST /stream/restart
90 |
91 | ### Description
92 |
93 | Restarts the stream, if running will stop and then start, if not running will simply start.
94 |
95 | ### Body Params
96 |
97 | None
98 |
99 | ### Example Successful Response:
100 |
101 | ```
102 | {
103 | "message": "OK"
104 | }
105 | ```
106 |
107 | ## GET /history
108 |
109 | ### Description
110 |
111 | Returns the recorded history activites. This will return an array under the `history` key. To limit the size of this response, use the `number_of_history_items` in the `config.json`.
112 |
113 | ### Query Params
114 |
115 | None
116 |
117 | ### Example Successfule Response:
118 |
119 | Only one thing played on the stream so far in this history example
120 |
121 | ```
122 | {
123 | "history": [
124 | {
125 | "audio": {
126 | "path": "/Users/aaron/Source/piStreamRadio/live-stream-radio/interludes/audio/tapeDeckSound.mp3",
127 | "metadata": {
128 | "track": {
129 | "no": null,
130 | "of": null
131 | },
132 | "disk": {
133 | "no": null,
134 | "of": null
135 | },
136 | "artists": [
137 | "Stephan"
138 | ],
139 | "artist": "Stephan",
140 | "title": "tapedeck sound",
141 | "encodersettings": "Lavf57.72.101"
142 | }
143 | },
144 | "video": {
145 | "path": "/Users/aaron/Source/piStreamRadio/live-stream-radio/interludes/video/example 1.gif"
146 | },
147 | "date": 1538637389778
148 | }
149 | ]
150 | }
151 | ```
152 |
153 | ## GET /library/audio
154 |
155 | ### Description
156 |
157 | This will return information about all found audio files in the project by following the `config.json`.
158 |
159 | ### Query Params
160 |
161 | * `include_metadata` - if true, will also include the common metadata for the found audio files.
162 |
163 | ### Example Successful Response
164 |
165 | Only showing one item from the generated example content. And used the `include_metadata` query param.
166 |
167 | ```
168 | {
169 | "audio": [
170 | {
171 | "path": "/Users/aaron/Source/piStreamRadio/live-stream-radio/audio/Aviscerall - Just Livin' - 05 Unhappy.mp3",
172 | "metadata": {
173 | "track": {
174 | "no": 5,
175 | "of": null
176 | },
177 | "disk": {
178 | "no": null,
179 | "of": null
180 | },
181 | "title": "Unhappy",
182 | "artists": [
183 | "Aviscerall"
184 | ],
185 | "artist": "Aviscerall",
186 | "album": "Just Livin'",
187 | "year": 2017,
188 | "comment": [
189 | "Visit http://aviscerall.bandcamp.com"
190 | ],
191 | "albumartist": "Aviscerall"
192 | }
193 | },
194 | ]
195 | }
196 | ```
197 |
198 | ## GET /config
199 |
200 | ### Description
201 |
202 | Returns a param of the config.json. If no key is set, all config params will be returned
203 |
204 | ### Query Params
205 |
206 | * `key` - The config key in dot-notation (example: 'api.host', 'interlude.overlay.enabled')
207 |
208 | ### Example Response:
209 | **HTTP 200** - Successful
210 | ```
211 | {
212 | "key": "interlude.enabled",
213 | "value": true
214 | }
215 | ```
216 | **HTTP 404** - Key not existant
217 | ```
218 | {
219 | "key": "interlude.awesomeness_level",
220 | "value": null
221 | }
222 | ```
223 |
224 | ## POST /config
225 |
226 | ### Description
227 |
228 | Changes a config param in the config.json. If they key has no value, it will be added
229 |
230 | ### Body Params
231 |
232 | * `key` - The config key in dot-notation (example: 'api.host', 'interlude.overlay.enabled')
233 | * `value` - The new value for the config in json (example: (string) "test", (number) 5, (boolean) true)
234 |
235 | ### Example Response:
236 | **HTTP 200** - Successful
237 | ```
238 | {
239 | "response": {
240 | "key": "interlude.frequency",
241 | "oldValue": "0.5",
242 | "newValue": "0.2"
243 | }
244 | }
245 | ```
246 | **HTTP 200** - Key not existant, new key will be saved
247 | ```
248 | {
249 | "response": {
250 | "key": "interlude.awesomeness_level",
251 | "newValue": "over 9000"
252 | }
253 | }
254 | ```
255 |
--------------------------------------------------------------------------------
/src/api/frontendDevelopment.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Frontend Development
3 | route: /api/frontenddevelopment
4 | menu: API
5 | ---
6 |
7 | # Frontend Development
8 |
9 | Frontends for `live-stream-radio` are very much appreciated, and should be seperated from the main Project CLI/API. Please open an issue on the Github repo if you would like your frontend mentioned on the project README or Documentation.
10 |
11 | ## Tips for Building Frontends
12 |
13 | Two types of frontends that could be built are Server Side Rendered Frontends, and Client Side Frontends. Either way, looking through the API endpoints is a great place to start.
14 |
15 | ### Server Side Rendered Frontends
16 |
17 | An **example** of a possible server side rendered frontend is an application that is build in [Express](https://github.com/expressjs/express). Authentication using the API key in the `config.json` could be passed through an environment variable or flag for the application. Then a more secure/proper authentication could be handled by your application. Express could then act as the proxy between the stream API, and the user, and render views as static pages, or in a Javascript framework like [React](https://reactjs.org/).
18 |
19 | ### Client Side Frontends
20 |
21 | An **example** of a possible client side frontend is an application written using [Preact](https://preactjs.com/) or [Angular](https://angular.io/). The API key for authentication could be prompted by the user on "login", and then stored in localstorage to act as the authentication token. This would require exposing the API publicly, and honestly, isn't very secure. Thus I would suggest putting the client behind something at least slightly more secure like basic auth. After the API Key is stored, then it could be used to access the API endpoints by the user.
22 |
--------------------------------------------------------------------------------
/src/api/index.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk');
2 | const fastify = require('fastify')({});
3 |
4 | // www-form-urlencoded parser for fastify
5 | fastify.register(require('fastify-formbody'));
6 |
7 | // Get our routes
8 | const addStreamRoutes = require('./stream.js');
9 | const addConfigRoutes = require('./config.js');
10 | const addLibraryRoutes = require('./library.js');
11 |
12 | let currentStream;
13 | let currentGetConfig;
14 |
15 | // Export our
16 | module.exports = {
17 | start: async (path, getConfig, stream) => {
18 | // save a reference to our stream and config
19 | currentStream = stream;
20 | currentGetConfig = getConfig;
21 |
22 | // Create our base "Hello world" route
23 | fastify.get('/', async (request, reply) => {
24 | reply.type('application/json').code(200);
25 | return { live_stream_radio: 'Please see documentation for endpoints and usage' };
26 | });
27 |
28 | // Implement our other routes
29 | addStreamRoutes(fastify, path, currentStream, currentGetConfig);
30 | addConfigRoutes(fastify, path, currentStream, currentGetConfig);
31 | addLibraryRoutes(fastify, path, currentStream, currentGetConfig);
32 |
33 | const config = await getConfig();
34 |
35 | await new Promise((resolve, reject) => {
36 | fastify.listen(config.api.port, config.api.host, (err, address) => {
37 | if (err) {
38 | reject(err);
39 | }
40 | console.log('\n');
41 | console.log(`${chalk.blue('API Started at:')} ${address}`);
42 | resolve();
43 | });
44 | });
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/src/api/library.js:
--------------------------------------------------------------------------------
1 | const find = require('find');
2 | const musicMetadata = require('music-metadata');
3 |
4 | const authService = require('./auth');
5 | const supportedFileTypes = require('../supportedFileTypes');
6 |
7 | const getAllAudio = async (path, getConfig) => {
8 | // Find al of our files with the extensions
9 | let allFiles = [];
10 | const config = await getConfig();
11 | supportedFileTypes.supportedAudioTypes.forEach(extension => {
12 | allFiles = [...allFiles, ...find.fileSync(extension, `${path}${config.radio.audio_directory}`)];
13 | });
14 |
15 | return allFiles;
16 | };
17 |
18 | const getAllAudioWithMetadata = async (path, getConfig) => {
19 | const audioFiles = await getAllAudio(path, getConfig);
20 |
21 | const allMetadata = [];
22 | const metadataPromises = [];
23 | audioFiles.forEach(audioFile => {
24 | const getAudioMetadataTask = async () => {
25 | const metadata = await musicMetadata.parseFile(audioFile, { duration: true });
26 | const metadataCommon = metadata.common;
27 | delete metadataCommon.picture;
28 | allMetadata.push({
29 | path: audioFile,
30 | metadata: metadataCommon
31 | });
32 | };
33 |
34 | metadataPromises.push(getAudioMetadataTask());
35 | });
36 |
37 | await Promise.all(metadataPromises);
38 | return allMetadata;
39 | };
40 |
41 | // File to return all of our /radio/* routes
42 | module.exports = (fastify, path, stream, getConfig) => {
43 | fastify.get(
44 | '/library/audio',
45 | authService.secureRouteHandler(getConfig, async (request, reply) => {
46 | let response;
47 | if (request.query.include_metadata !== undefined) {
48 | response = await getAllAudioWithMetadata(path, getConfig);
49 | } else {
50 | response = await getAllAudio(path, getConfig);
51 | }
52 |
53 | reply.type('application/json').code(200);
54 | return {
55 | audio: response
56 | };
57 | })
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/src/api/stream.js:
--------------------------------------------------------------------------------
1 | const historyService = require('../history.service');
2 | const authService = require('./auth');
3 |
4 | // Function to perform all checks before performing route
5 | const preCheck = (stream, getConfig, fastify, request, reply) => {
6 | if (!stream) {
7 | reply.type('application/json').code(400);
8 | return {
9 | message: 'There is currently no stream'
10 | };
11 | }
12 |
13 | return;
14 | };
15 |
16 | // File to return all of our /stream/* routes
17 | module.exports = (fastify, path, stream, getConfig) => {
18 | // Get stream status
19 | fastify.get(
20 | '/stream',
21 | authService.secureRouteHandler(getConfig, async (request, reply) => {
22 | reply.type('application/json').code(200);
23 | return {
24 | isRunning: stream.isRunning()
25 | };
26 | })
27 | );
28 |
29 | // Start the stream
30 | fastify.post(
31 | '/stream/start',
32 | authService.secureRouteHandler(getConfig, async (request, reply) => {
33 | preCheckResponse = preCheck(stream, getConfig, fastify, request, reply);
34 | if (preCheckResponse) {
35 | return preCheckResponse;
36 | }
37 |
38 | if (!stream.isRunning()) {
39 | await stream.start();
40 | }
41 |
42 | reply.type('application/json').code(200);
43 | return {
44 | message: 'OK'
45 | };
46 | })
47 | );
48 |
49 | // Stop the stream
50 | fastify.post(
51 | '/stream/stop',
52 | authService.secureRouteHandler(getConfig, async (request, reply) => {
53 | preCheckResponse = preCheck(stream, getConfig, fastify, request, reply);
54 | if (preCheckResponse) {
55 | return preCheckResponse;
56 | }
57 |
58 | if (stream.isRunning()) {
59 | await stream.stop();
60 | }
61 |
62 | reply.type('application/json').code(200);
63 | return {
64 | message: 'OK'
65 | };
66 | })
67 | );
68 |
69 | // Restart the stream
70 | fastify.post(
71 | '/stream/restart',
72 | authService.secureRouteHandler(getConfig, async (request, reply) => {
73 | preCheckResponse = preCheck(stream, getConfig, fastify, request, reply);
74 | if (preCheckResponse) {
75 | return preCheckResponse;
76 | }
77 |
78 | if (stream.isRunning()) {
79 | await stream.stop();
80 | }
81 |
82 | // Wrap in a set timeout, that way it wont crash and ffmpeg can continue
83 | setTimeout(() => {
84 | stream.start();
85 | }, 1000);
86 |
87 | reply.type('application/json').code(200);
88 | return {
89 | message: 'OK'
90 | };
91 | })
92 | );
93 |
94 | // Stream History
95 | fastify.get(
96 | '/stream/history',
97 | authService.secureRouteHandler(getConfig, async (request, reply) => {
98 | reply.type('application/json').code(200);
99 | return {
100 | history: historyService.getHistory()
101 | };
102 | })
103 | );
104 |
105 | // Returns 405
106 | fastify.post(
107 | '/stream',
108 | authService.secureRouteHandler(getConfig, async (request, reply) => {
109 | reply.type('application/json').code(405);
110 | return {};
111 | })
112 | );
113 | fastify.get(
114 | '/stream/start',
115 | authService.secureRouteHandler(getConfig, async (request, reply) => {
116 | reply.type('application/json').code(405);
117 | return {};
118 | })
119 | );
120 | fastify.get(
121 | '/stream/stop',
122 | authService.secureRouteHandler(getConfig, async (request, reply) => {
123 | reply.type('application/json').code(405);
124 | return {};
125 | })
126 | );
127 | fastify.get(
128 | '/stream/restart',
129 | authService.secureRouteHandler(getConfig, async (request, reply) => {
130 | reply.type('application/json').code(405);
131 | return {};
132 | })
133 | );
134 | fastify.post(
135 | '/stream/history',
136 | authService.secureRouteHandler(getConfig, async (request, reply) => {
137 | reply.type('application/json').code(405);
138 | return {};
139 | })
140 | );
141 | };
142 |
--------------------------------------------------------------------------------
/src/frontendsAndNotableProjects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Frontends & Notable Projects
3 | route: /frontends-and-notable-projects
4 | order: 7
5 | ---
6 |
7 | # API Frontends
8 |
9 | _For building your own API frontend, please see the [API Documentation](https://torch2424.github.io/live-stream-radio/api/frontenddevelopment) 📚 on API Endpoints._
10 |
11 | Currently, there are no supported API frontends. However, Contributions are welcome! If you make a `live-stream-radio` frontend, please open an issue and so we can add the project here 😄!
12 |
13 | # Other Notable Projects
14 |
15 | * [lsr-wrapper](https://github.com/LSRemote/lsr-wrapper) - A Promise based wrapper around the `live-stream-radio` api.
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/generate/FAQ.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: FAQ
3 | menu: Project Management
4 | route: /projectmanagement/FAQ
5 | ---
6 |
7 | # FAQ
8 |
9 | ## What are the Supported File Types
10 |
11 | | Type | Supported Filetypes |
12 | | - | - |
13 | | Audio | `.mp3`, `.wav`, `.flac` |
14 | | Video | `.mp4`, `.gif`, `.avi`, `.mov`, `.mkv`, `.webm` |
15 | | Font | `.ttf` |
16 | | Image | `.png` |
17 |
18 | In terms of encoding video, `.gif` files are the slowest as they need to be pre-encoded before encoded onto the stream.
19 |
20 | ## What are some reccomended technical stream settings
21 |
22 | Here is the [Youtube Reccomended Livestream](https://support.google.com/youtube/answer/2853702?hl=en) configuration for streaming software, as well as the [Twitch Reccomended Settings](https://stream.twitch.tv/).
23 |
24 | ## How is Audio Metadata determined
25 |
26 | Audio metadata is collected using [music-metadata](https://www.npmjs.com/package/music-metadata). I'd suggest ensuring that all of your audio has the correct metadata before uploading it to your project.
27 |
28 | ## How are the overlays layered
29 |
30 | The overlays are layered over one another in the following order:
31 |
32 | * Video (bottom)
33 | * Image
34 | * Text (top)
35 |
36 | Also, as a comparison to CSS z-index, The video could be 0, the image could be 1, and the text could be 2.
37 |
38 | Also, this means that if we enabled the image overlay, it should contain transparency to see the video underneath.
39 |
40 | ## Can I Edit the Project While it is Running
41 |
42 | *In Theory*, yes. You should be able to add files while the stream is running, and have them added to be streamed at a later point. However, be cautious of deleting files, as they may be streaming at the time of deletion and cause a crash.
43 |
44 | Also, be cautious editing the `config.json`, as having a bad config will exit the process.
45 |
46 |
--------------------------------------------------------------------------------
/src/generate/config.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Config
3 | menu: Project Management
4 | route: /projectmanagement/config
5 | ---
6 |
7 | # Config
8 |
9 | This page will go in detail with every field of a projects `config.json`. If you ever need to reset the default settings, or see an example `config.json`, please check out the [default config.json](https://github.com/torch2424/live-stream-radio/blob/master/src/generate/template/config.json).
10 |
11 | ## Settings Fields
12 |
13 | | Field | Usage |
14 | | - | - |
15 | | stream_url | The URL of the stream. You can use $stream_key as a variable, this will replace it with the value of stream_key. You can overwrite this field with the `--output` CLI flag. |
16 | | stream_key | The stream key provided by your stream platform. |
17 | | ffmpeg_path | The path to your ffmpeg binaries. If this field is left empty, the project will try to find ffmpeg binaries specified in your path. |
18 | | video_codec | The codec used by ffmpeg to encode the stream video output. |
19 | | audio_codec | The codec used by ffmpeg to encode the stream audio output. |
20 | | audio_bit_rate | The bit rate of the stream audio output. |
21 | | audio_sample_rate | The audio sample rate of the stream audio output. |
22 | | audio_bit_rate | The audio bitrate of your stream. |
23 | | audio_sample_rate | The audio sample rate of your steam. |
24 | | normalize_audio | True/false value to enable or disable audio normalization across the stream. This can degrade audio quality. |
25 | | video_height | The height of your stream. |
26 | | video_width | The width of your stream. |
27 | | video_bit_rate | The bitrate of your stream. |
28 | | video_fps | The fps (frames per second) of your stream. |
29 | | bufsize | Buffer size for the stream output, denoted by `-bufsize` flag in FFmpeg |
30 | | crf | Standard constant rate used by FFmpeg used to improve streaming. Denoted by the `-crf` flag in FFmpeg. |
31 | | preset | Preset used by FFmpeg to acheive a balance of quality and performance in FFmpeg, denoted by the `-preset` flag in FFmpeg. |
32 | | threads | Number of threads to limit the FFmpeg process to. Denoted by the `-threads` flag in FFmpeg. |
33 | | max_gif_size | The maximum width or height of a gif for your stream. This is used by the optimization used on gifs. |
34 | | api.host | Host of the API. (usually localhost) |
35 | | api.port | Port on which the API runs on. |
36 | | api.key | API Key used for authentication. This is basically your admin password, so keep it secret! |
37 | | api.number_of_history_items | Number of items to store in the streams history to get the last played songs. |
38 | | radio.audio_directory | Path to your music. Check out the FAQ for supported file types. |
39 | | radio.video_directory | Path to your videos and gifs. Check out the FAQ for supported file types. |
40 | | radio.overlay.enabled | Enables/Disables overlays. |
41 | | radio.overlay.font_path | Path to the fonts used for the stream overlays. Check out the FAQ for supported file types. |
42 | | radio.overlay.title.enable_scroll | Enables horizontal scrolling of the title. |
43 | | radio.overlay.title.font_scroll_speed | The speed at which the title scrolls. |
44 | | radio.overlay.artist | Common Text Object for the artist. |
45 | | radio.overlay.album | Common Text Object for the album. |
46 | | radio.overlay.song | Common Text Object for the song. |
47 | | radio.overlay.image | Common Image Object for an overlay over the text and video. |
48 | | interlude.enabled | Enables interludes which are are transition videos/audios played in-between songs. |
49 | | interlude.frequency | Percentage of how often an interlude should be played. Valid values are from 0.0 (never) to 1.0 (100% of the time). |
50 | | interlude.audio_directory | Path to your interlude music. Check out the FAQ for supported file types. |
51 | | interlude.video_directory | Path to your interlude videos and gifs. Check out the FAQ for supported file types. |
52 | | interlude.overlay.enabled | Enables overlays in interludes. |
53 | | interlude.overlay.font_path | Path to the fonts used for the stream overlays. Check out the FAQ for supported file types. |
54 | | interlude.overlay.title | Common Text Object for the a title. |
55 | | interlude.overlay.image | Common Image Object for an overlay over the text and video. |
56 |
57 | ### Video HeightWidth Ratio
58 | These are common values for the `video_height` and `video_width` fields.
59 |
60 | | Quality | Width | Height |
61 | | - | - | - |
62 | | 240p | 426 | 240 |
63 | | 360p | 640 | 360 |
64 | | 480p | 854 | 480 |
65 | | 720p | 1280 | 720|
66 | | 1080p | 1920 | 1080 |
67 |
68 | ## Common Text Object
69 |
70 | Common Text Objects contain all settings required to display text.
71 |
72 | | Field | Usage |
73 | | - | - |
74 | | enabled | Enables/Disables this text object |
75 | | text | Text string for this object |
76 | | font_color | HEX color with leading # of the fonts color |
77 | | font_border | HEX color with leading # of the fonts border color |
78 | | font_size | Size of the font |
79 | | position_x | Percentage value (without the %) of the X position of the font. |
80 | | position_y | Percentage value (without the %) of the Y position of the font. |
81 |
82 | ### Example
83 | ```
84 | "some-text-object": {
85 | "enabled": true
86 | "text": "Live Stream Radio, 24/7 Open Source Radio",
87 | "font_color": "#FFFFFF",
88 | "font_border": "#000000",
89 | "font_size": "10",
90 | "position_x": "0",
91 | "position_y": "5"
92 | }
93 | ```
94 |
95 | ## Common Image Object
96 |
97 | Common Image Objects contain all settings required to display an image.
98 |
99 | | Field | Usage |
100 | | - | - |
101 | | enabled | Enables/Disables this image object |
102 | | image_path | Path to the image. Check out the FAQ for supported file types. |
103 | | position_x | Horizontal position of the image in pixels from left. |
104 | | position_y | Vertical position of the image in pixels from the top. |
105 |
106 | ### Example
107 | ```
108 | "image": {
109 | "enabled": true,
110 | "image_path": "./live-stream-radio-overlay-image.png",
111 | "position_x": 0,
112 | "position_y": 0
113 | }
114 | ```
115 |
116 | ## Dynamic Config
117 |
118 | It is possible to add extra functionality to how `live-stream-radio` parses your config.json per song. For example, if you would like to build:
119 |
120 | * A radio station that plays certain songs during a certain time of day
121 | * Shows a different title depending on the last song played
122 | * Uses an API to decide what song to play
123 |
124 | This can be done by adding a `config.js` to the root of your project. In other words, in the same root of the project that your `config.json` resides. This `config.js` simply exports a single [async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) function, or function that returns a promise, that resolves an object representing a `config.json`. This function takes in a projectPath, and a streamState Object and can use those before deciding any final decisions. **Note:** `config.js` will take precendence over any `config.json`, and it is reccomended that you use your config.json as a fallback and resolved by your `config.js` exported async function.
125 |
126 | ### Function Definition
127 |
128 | **Params**
129 |
130 | * path (string) - Absolute path of the current project directory
131 |
132 | * streamState (Object) - Object containing various information about the stream
133 |
134 | | streamState Field | Usage |
135 | | - | - |
136 | | history | History of the activites performed on the stream, recorded in the history service. See [/live-stream-radio/api/endpoints#get-history] |
137 |
138 | ### Example
139 |
140 | So let's take the first example, **A radio station that plays certain songs during a certain time of day**. What we can do is make seperate folders in our project for each hour on a 24 hour time, and use [getHours()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getHours) to get the current hour of the day.
141 |
142 | **config.js**
143 |
144 | ```js
145 | module.exports = async (path, streamState) => {
146 | let config = require(`${path}config.json`);
147 |
148 | const date = new Date();
149 | const currentHour = date.getHours();
150 | config.radio.audio_directory = `./my/audio/directory/${currentHour}`;
151 |
152 | return config;
153 | }
154 | ```
--------------------------------------------------------------------------------
/src/generate/generate.js:
--------------------------------------------------------------------------------
1 | // Require our dependencies
2 | const chalk = require('chalk');
3 | const fs = require('fs-extra');
4 |
5 | module.exports = projectName => {
6 | // Add a default project name if none
7 | if (!projectName) {
8 | projectName = 'live-stream-radio';
9 | }
10 |
11 | // Inform user of project creation
12 | console.log('🎵', chalk.green('Generating a new pi-stream-radio project in:'), chalk.blue(projectName), '🎵');
13 |
14 | // Create our new project directory
15 | const newProjectPath = `${process.cwd()}/${projectName}`;
16 | console.log('📁', chalk.magenta(`Creating a directory at ${newProjectPath} ...`));
17 | fs.mkdirSync(newProjectPath);
18 |
19 | // Fill the project diretory with the template
20 | createDirectoryContents(process.cwd(), `${__dirname}/template`, projectName);
21 |
22 | console.log(chalk.green(`Project created at: ${newProjectPath} !`), '🎉');
23 | };
24 |
25 | // Function to generate out or template project
26 | // https://medium.com/northcoders/creating-a-project-generator-with-node-29e13b3cd309
27 | function createDirectoryContents(currentPath, templatePath, newProjectPath) {
28 | const filesToCreate = fs.readdirSync(templatePath);
29 |
30 | filesToCreate.forEach(file => {
31 | if (!file) {
32 | return;
33 | }
34 |
35 | const origFilePath = `${templatePath}/${file}`;
36 |
37 | // get stats about the current file
38 | const stats = fs.statSync(origFilePath);
39 |
40 | const writePath = `${currentPath}/${newProjectPath}/${file}`;
41 |
42 | if (stats.isFile()) {
43 | console.log('📝', chalk.magenta(`Copying file to ${writePath} ...`));
44 | fs.copySync(origFilePath, writePath);
45 | } else if (stats.isDirectory()) {
46 | console.log('📁', chalk.magenta(`Creating a directory at ${writePath} ...`));
47 | fs.mkdirSync(writePath);
48 |
49 | // recursive call
50 | createDirectoryContents(currentPath, `${templatePath}/${file}`, `${newProjectPath}/${file}`);
51 | }
52 | });
53 | }
54 |
--------------------------------------------------------------------------------
/src/generate/template/audio/Aviscerall - Just Livin' - 05 Unhappy.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/audio/Aviscerall - Just Livin' - 05 Unhappy.mp3
--------------------------------------------------------------------------------
/src/generate/template/audio/Aviscerall - Lp's and Love Songs - 01 I Am a Fool for Beauty.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/audio/Aviscerall - Lp's and Love Songs - 01 I Am a Fool for Beauty.mp3
--------------------------------------------------------------------------------
/src/generate/template/audio/Aviscerall - Nostalgia Infinite - 04 Scattered Dreams.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/audio/Aviscerall - Nostalgia Infinite - 04 Scattered Dreams.mp3
--------------------------------------------------------------------------------
/src/generate/template/audio/Miata feat Of The Other Time.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/audio/Miata feat Of The Other Time.mp3
--------------------------------------------------------------------------------
/src/generate/template/audio/nestedDirectoriesWork/Aviscerall - Cruisin' - 01 Sanctuary.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/audio/nestedDirectoriesWork/Aviscerall - Cruisin' - 01 Sanctuary.mp3
--------------------------------------------------------------------------------
/src/generate/template/audio/nestedDirectoriesWork/Aviscerall - Just Livin' - 07 Ascension feat. Groovy Godzilla.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/audio/nestedDirectoriesWork/Aviscerall - Just Livin' - 07 Ascension feat. Groovy Godzilla.mp3
--------------------------------------------------------------------------------
/src/generate/template/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "stream_url": "rtmp://a.rtmp.youtube.com/live2/$stream_key",
3 | "stream_key": "",
4 | "ffmpeg_path": "",
5 | "video_codec": "libx264",
6 | "audio_codec": "aac",
7 | "audio_bit_rate": "128k",
8 | "audio_sample_rate": "44100",
9 | "normalize_audio": true,
10 | "video_height": "720",
11 | "video_width": "1280",
12 | "video_bit_rate": "2500k",
13 | "video_fps": "25",
14 | "bufsize": "2500k",
15 | "crf": "28",
16 | "preset": "superfast",
17 | "threads": 2,
18 | "max_gif_size": "720",
19 | "api": {
20 | "host": "localhost",
21 | "port": "8000",
22 | "key": "super-secret-api-key",
23 | "number_of_history_items": 100
24 | },
25 | "radio": {
26 | "audio_directory": "./audio",
27 | "video_directory": "./video",
28 | "overlay": {
29 | "enabled": true,
30 | "font_path": "./fonts/Lato-Regular.ttf",
31 | "title": {
32 | "enabled": true,
33 | "text": "Live Stream Radio, 24/7 Open Source Radio",
34 | "font_color": "#FFFFFF",
35 | "font_border": "#000000",
36 | "font_size": "10",
37 | "enable_scroll": true,
38 | "font_scroll_speed": "20",
39 | "position_x": "0",
40 | "position_y": "5"
41 | },
42 | "artist": {
43 | "enabled": true,
44 | "label": "Artist: ",
45 | "font_color": "#FFFFFF",
46 | "font_border": "#000000",
47 | "font_size": "10",
48 | "position_x": "2",
49 | "position_y": "15"
50 | },
51 | "album": {
52 | "enabled": false,
53 | "label": "Album: ",
54 | "font_color": "#FFFFFF",
55 | "font_border": "#000000",
56 | "font_size": "10",
57 | "position_x": "2",
58 | "position_y": "25"
59 | },
60 | "song": {
61 | "enabled": true,
62 | "label": "Song: ",
63 | "font_color": "#FFFFFF",
64 | "font_border": "#000000",
65 | "font_size": "10",
66 | "position_x": "2",
67 | "position_y": "25"
68 | },
69 | "image": {
70 | "enabled": true,
71 | "image_path": "./live-stream-radio-overlay-image.png",
72 | "position_x": 0,
73 | "position_y": 0
74 | }
75 | }
76 | },
77 | "interlude": {
78 | "enabled": "true",
79 | "frequency": "0.2",
80 | "audio_directory": "./interludes/audio",
81 | "video_directory": "./interludes/video",
82 | "overlay": {
83 | "enabled": true,
84 | "font_path": "./fonts/Lato-Regular.ttf",
85 | "title": {
86 | "enabled": true,
87 | "text": "Please wait, music shall resume shortly...",
88 | "font_color": "#FFFFFF",
89 | "font_border": "#000000",
90 | "font_size": "10",
91 | "position_x": "2",
92 | "position_y": "5"
93 | },
94 | "image": {
95 | "enabled": true,
96 | "image_path": "./live-stream-radio-overlay-image.png",
97 | "position_x": 0,
98 | "position_y": 0
99 | }
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/generate/template/fonts/Lato-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/fonts/Lato-Regular.ttf
--------------------------------------------------------------------------------
/src/generate/template/interludes/audio/nestedDirectoriesWork/tapeDeckSound.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/interludes/audio/nestedDirectoriesWork/tapeDeckSound.mp3
--------------------------------------------------------------------------------
/src/generate/template/interludes/audio/tapeDeckSound.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/interludes/audio/tapeDeckSound.mp3
--------------------------------------------------------------------------------
/src/generate/template/interludes/video/example 1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/interludes/video/example 1.gif
--------------------------------------------------------------------------------
/src/generate/template/live-stream-radio-overlay-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/live-stream-radio-overlay-image.png
--------------------------------------------------------------------------------
/src/generate/template/video/nestedDirectoriesWork/publicDomainEarth.webm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/video/nestedDirectoriesWork/publicDomainEarth.webm
--------------------------------------------------------------------------------
/src/generate/template/video/publicDomainEarth.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/torch2424/live-stream-radio/b37687769b8207e34c1c4d05999337a1981bdae3/src/generate/template/video/publicDomainEarth.mp4
--------------------------------------------------------------------------------
/src/generate/tips.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Tips
3 | menu: Project Management
4 | route: /projectmanagement/tips
5 | ---
6 |
7 | # Tips
8 |
9 | ## File Management
10 |
11 | I'd suggest using a lightweight file server like [Droppy](https://github.com/silverwind/droppy) to allow easy access to your stream files. Also, Droppy will let you edit the `config.json` file on the server itself! Another file server I could suggest would be [Filerun](http://www.filerun.com/), it is a lot heavier than Droppy, but offers a more robust interface, and file meta data editing!
12 |
13 | ## Systemd / Run on Boot
14 |
15 | On Ubuntu and Raspbian machine, you can get the stream to run on boot using systemd. I have a [systemctl service](https://github.com/torch2424/dotFiles/blob/master/.files_templates/systemctl.service) in my personal dotfiles I fill out to do this. I'd suggest looking there, and the links I provided in the comments to see how to get up and running with this. Another alternative is to look into setting this up in a crontab using `@reboot`.
16 |
17 | ## Config Settings
18 |
19 | For more context on the config settings, see this [FFmpeg wiki on encoding for streaming](https://trac.ffmpeg.org/wiki/EncodingForStreamingSites). If you are familiar with FFmpeg and it's usage, see the [`stream.js`](https://github.com/torch2424/live-stream-radio/blob/master/src/stream/stream.js) source file for how FFmpeg flags are bing used.
20 |
--------------------------------------------------------------------------------
/src/history.service.js:
--------------------------------------------------------------------------------
1 | // Singleton service to store the past 100 activites
2 | const history = [];
3 | let numberOfHistoryItems = 100;
4 |
5 | module.exports = {
6 | getHistory: () => {
7 | return history;
8 | },
9 | setNumberOfHistoryItems: number => {
10 | numberOfHistoryItems = number;
11 | },
12 | addItemToHistory: item => {
13 | const historyItem = {
14 | ...item
15 | };
16 |
17 | historyItem.date = Date.now();
18 |
19 | history.push(historyItem);
20 |
21 | if (history.length > 100) {
22 | history.splice(history.length - 100, 100);
23 | }
24 | }
25 | };
26 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | // Parse our input
4 | const argv = require('minimist')(process.argv.slice(2), {
5 | string: ['help', 'generate', 'output', 'start'],
6 | alias: {
7 | h: ['help'],
8 | v: ['version'],
9 | g: ['generate'],
10 | o: ['output'],
11 | s: ['start']
12 | }
13 | });
14 |
15 | // Check if we should print our usage
16 | if ((Object.keys(argv).length === 1 && argv._.length <= 0) || argv.help !== undefined) {
17 | const chalk = require('chalk');
18 | const pkg = require('../package.json');
19 |
20 | console.log(`
21 | ${chalk.blue('USAGE:')} ${chalk.yellow(pkg.name)}
22 |
23 | ${chalk.blue('--help, -h')} : Print this usage message.
24 | ${chalk.blue('--version, -v')} : Print the current version of this installation.
25 | ${chalk.blue('--generate, -g')} ${chalk.magenta('[Project Name/Directory]')} : Generate a new stream project,
26 | in a directory with the Project name.
27 | ${chalk.blue('--output, -o')} ${chalk.magenta('[Stream Output Location]')} : Override the 'stream_url/stream_key',
28 | in the config.json, and output to the location.
29 | Helpful for testing output and development.
30 | ${chalk.blue('--start, -s')} ${chalk.magenta('[Project Name/Directory]')} : Start the stream using the passed directory.
31 | ${chalk.yellow('Default:')}
32 | Will assume the --start flag if no flag is passed.
33 | E.g
34 |
35 | ${pkg.name} [Project Name/Directory]
36 | Will Become:
37 | ${pkg.name} --start [Project Name/Directory]
38 | `);
39 |
40 | process.exit(0);
41 | }
42 |
43 | // Check if we would like to print the installed version
44 | if (argv.version !== undefined) {
45 | const jsonPackage = require('../package.json');
46 | console.log(jsonPackage.version);
47 | process.exit(0);
48 | }
49 |
50 | // Check if we would like to generate a project
51 | if (argv.generate !== undefined) {
52 | // Call the generate from generator
53 | require('./generate/generate')(argv.generate);
54 | process.exit(0);
55 | }
56 |
57 | // Start the server
58 | const fs = require('fs');
59 | const chalk = require('chalk');
60 |
61 | const historyService = require('./history.service');
62 |
63 | // Check if we passed in a base path
64 | let path = process.cwd();
65 | if ((argv.start && argv.start.length > 0) || argv._.length > 0) {
66 | path = `${process.cwd()}/${argv.start || argv._[0]}`;
67 | }
68 |
69 | // Add a trailing slash to out path if there isn't one
70 | const lastPathChar = path.substr(-1);
71 | if (lastPathChar != '/') {
72 | path += '/';
73 | }
74 |
75 | // Find if we have a config in the path
76 | const configJsonPath = `${path}config.json`;
77 | const configJsPath = `${path}config.js`;
78 | let getConfig = undefined;
79 | // First check if we have a config.js
80 | if (fs.existsSync(configJsPath)) {
81 | console.log(`${chalk.green('Using the config.js at:')} ${configJsPath}`);
82 | const configExport = require(configJsPath);
83 |
84 | // Wrap get config in all of our stateful service
85 | getConfig = async () => {
86 | let config = undefined;
87 | try {
88 | config = await configExport(path, {
89 | history: historyService.getHistory()
90 | });
91 | } catch (e) {
92 | console.log(`${chalk.red('error calling the config.js!')} 😞`);
93 | console.log(e.message);
94 | process.exit(1);
95 | }
96 |
97 | return config;
98 | };
99 | } else if (fs.existsSync(configJsonPath)) {
100 | console.log(`${chalk.magenta('Using the config.json at:')} ${configJsonPath}`);
101 | // Simply set our config to a function that just returns the static config.json
102 | getConfig = async () => {
103 | let configJson = undefined;
104 | try {
105 | configJson = require(configJsonPath);
106 | } catch (e) {
107 | console.log(`${chalk.red('error reading the config.json!')} 😞`);
108 | console.log(e.message);
109 | process.exit(1);
110 | }
111 |
112 | return configJson;
113 | };
114 | } else {
115 | // Tell them could not find a config file
116 | console.log(`${chalk.red('Error did not find a config.json at:')} ${configJsonPath} 😞`);
117 | if (typeof e !== 'undefined') {
118 | console.log(e.message);
119 | }
120 | process.exit(1);
121 | }
122 |
123 | // Async task to start the radio
124 | const startRadioTask = async () => {
125 | // Define our stream
126 | let stream = require('./stream/index.js');
127 |
128 | // Start the api
129 | const api = require('./api/index.js');
130 | await api.start(path, getConfig, stream);
131 |
132 | // Set our number of history items
133 | const config = await getConfig();
134 | historyService.setNumberOfHistoryItems(config.api.number_of_history_items);
135 |
136 | // Start our stream
137 | await stream.start(path, getConfig, argv.output);
138 | };
139 | startRadioTask();
140 |
--------------------------------------------------------------------------------
/src/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Introduction
3 | route: /
4 | order: 0
5 | ---
6 |
7 | # Introduction
8 | `live-stream-radio` is a 24/7 live stream radiostation CLI built with Node.js and powered by FFmpeg. This means that you'll be able to start a livestream radio for music, a looping gif/video, music information and other overlay items. This project is highly customizable and also features a REST HTTP JSON API for frontend interfaces.
9 |
10 | To get a first glimpse on how to use this project, head over to the [Getting Started](/cli/getting-started) page!
11 |
12 |
13 |
14 | ## Compatibility
15 | We try to make this project compatible with any OS that supports both NodeJS and FFmpeg. This project even can run on lower end hardware, such as [$5 Digital Ocean Droplets](https://www.digitalocean.com/pricing/).
16 |
17 | ## Contributing
18 |
19 | Feel free to fork this project, open up issues and even push pull-requests to make this project better! We suggest that you first open up an issue before working on a pull-request so everyone is on the same page and can discuss on the proposed changes!
20 |
21 | To access the GitHub repo, simply click on the GitHub corner on the top-right.
22 |
23 | ## License
24 | ### Apache
25 | LICENSE under [Apache 2.0](https://choosealicense.com/licenses/apache-2.0/). 🐦
26 |
27 | ### FFmpeg
28 | This software uses code of [FFmpeg](http://ffmpeg.org/) licensed under the [LGPLv2.1](http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html) and its source can be downloaded [here](https://github.com/torch2424/live-stream-radio/blob/master/deps/ffmpeg).
29 |
30 | As such, this software tries to respect the LGPLv2 License as close as possible to respect FFmpeg and its authors. Huge shoutout to them for building such an awesome and crazy tool!
31 |
32 | ### Example Assets
33 | The generate command already comes with a bunch of example assets. Music is provided by [Aviscerall](https://aviscerall.bandcamp.com/), and [Marquice Turner](https://marquiceturner.bandcamp.com/) (Which is actually me, @torch2424, but I have a musical identity problem 😛). The test mp4 and webm is [this](https://www.youtube.com/watch?v=uuY1RXZyUFs) public domain YouTube video. The image overlay uses art assets from EmojiOne, more specifically [this video camera](https://www.emojione.com/emoji/1f4f9) and [this radio](https://www.emojione.com/emoji/1f4fb) emoji.
34 |
--------------------------------------------------------------------------------
/src/raspberryPi.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Raspberry Pi
3 | route: /raspberry-pi
4 | order: 6
5 | ---
6 |
7 | # Raspberry Pi
8 |
9 | This page contains tips for setting up `live-stream-radio` on a raspberry pi. If you have any performance issues, or are not comfortable using Node.js, feel free to use the older (no longer updated) version of the project, [piStreamRadio v1.2.0](https://github.com/torch2424/live-stream-radio/tree/1.2.0). Please read over the [CLI Getting Started](/cli/getting-started) to have context on what steps are required, and how these steps streamline the usual install process for Raspberry Pis.
10 |
11 | ### FFmpeg Installation
12 |
13 | Installing live-stream-radio on a raspberry pi follows the same proedures from the [CLI Getting Started](/cli/getting-started), however, there is a more streamlined way of getting FFmpeg installed. Since the project was formerly called `piStreamRadio`, we have an old script to compile FFmpeg for you on the [piStreamRadio v1.2.0 `additionalScripts/compileFFmpeg.sh`](https://github.com/torch2424/live-stream-radio/blob/1.2.0/additionalScripts/compileFFmpeg.sh). Re-iterating from the [CLI Getting Started](/cli/getting-started), **Note: this project requires FFmpeg version 4.2.0 or greater in order to ensure the best performance. [Please see this issue for explanation](https://github.com/torch2424/live-stream-radio/issues/78)**.
14 |
15 | ### Configuring your project
16 |
17 | After following the easy FFmpeg installation steps above, and completing the rest of the [CLI Getting Started](/cli/getting-started), here are some nice defaults for raspberry pi to get things performing better. You want to replace the values of the keys in your project `config.json` with the ones below:
18 |
19 | | Field | Usage |
20 | | - | - |
21 | | audio_codec | "libfdk_aac" |
22 | | crf | "32" |
23 | | preset | "ultrafast" |
24 |
25 | This should definitely help performance and reduce buffering for your viewers. Also, note, that decreasing "video_fps" may not actuall improve your performance, as this can affect how videos are encoded, and the rate at which this happens.
26 |
27 | ### Adding Video to your project
28 |
29 | We **Definitely** reccomend using the fastest formats for decoding so that they can be encoded faster and put less strain on the pi. We reccomend adding `.webm` to store your video, as for some reason FFmpeg tends to handle this to best.
30 |
31 |
--------------------------------------------------------------------------------
/src/stream/gettingstarted.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Getting Started
3 | menu: CLI
4 | route: /cli/getting-started
5 | ---
6 |
7 | # Getting Started
8 | ## Installation
9 | ### nvm
10 | First, you have to install the latest LTS version of [Node.js](https://nodejs.org/) (this also includes `npm`, the package manager of Node.js). We recommend you to do this by using `nvm`, the `node version manager`.
11 |
12 | Simply download nvm from [this (Mac/Linux)](https://github.com/creationix/nvm) or [this (Windows)](https://github.com/coreybutler/nvm-windows) website and install it. After that you'll be able to install the latest LTS version by running the following command in a terminal or console.
13 |
14 | `nvm install --lts`
15 |
16 | ### FFmpeg
17 | Next, you have to download or compile the latest version of [FFmpeg](http://ffmpeg.org/). It specifically needs to be compiled with [libfreetype](https://ffmpeg.org/ffmpeg-filters.html#drawtext) so we can draw text within the stream image. You can either add the folder of your ffmpeg binaries to your system's PATH or directly put the path to them in the config file. **Note: this project requires FFmpeg version 4.2.0 or greater in order to ensure the best performance. [Please see this issue for explanation](https://github.com/torch2424/live-stream-radio/issues/78)**
18 |
19 | #### Downloading FFmpeg
20 |
21 | We have a mock release page, where we can share Pre-compiled FFmpeg builds that we confirm work well with `live-stream-radio`. Check out the [FFmpeg pre-compiled builds here](https://github.com/torch2424/live-stream-radio/releases/tag/ffmpeg-builds).
22 |
23 | #### Compiling FFmpeg
24 | Please check out the [FFmpeg Compilation Guide](https://trac.ffmpeg.org/wiki/CompilationGuide) for a more detailled guide. Again, please check that you build FFmpeg with the libfreetype module. If you using macOS, building ffmpeg may be as easy as running `brew install ffmpeg --with-freetype` on your terminal.
25 |
26 | ### live-stream-radio
27 | We're getting somewhere! Next, you will finally install live-stream-radio. We recommend you to install it globally to use the command from everywhere! Installing is as easy as running the following command in a terminal/console.
28 |
29 | `npm install -g live-stream-radio`
30 |
31 | After that, you can check if you installed it correctly by running `live-stream-radio --help`!
32 |
33 | ## Generating a stream project
34 | Let's get to the good stuff! Now you have to generate your first stream project. Go ahead, fire up a terminal/console and run this command:
35 |
36 | `live-stream-radio --generate myStream`
37 |
38 | **myStream** can be any name for your stream. Keep in mind that this will also be the folder name, so don't get too crazy!
39 |
40 | Looking good! Now for some customization!
41 |
42 | ## Configuration
43 | check out the `config.json` file in your streams project folder. Put in your `stream_url` and `stream_key` and also check out the other settings. If you want to stream on YouTube, we even already setup the stream url for you (you might noticed the $stream_key in the url, this is a variable and will be replaced with the stream key!). Now might also be a good time to put your music and videos in their dedicated folders.
44 |
45 | ## Starting the stream
46 | Starting your newly created stream is as simple as running `live-stream-radio --start myStream`.
47 |
48 | Now you can get some popcorn and enjoy your live-stream-radio! Or, you might want to check out the [configuration](/projectmanagement/config) or [api](/api/endpoints)!
49 |
--------------------------------------------------------------------------------
/src/stream/gif.js:
--------------------------------------------------------------------------------
1 | const ffmpeg = require('fluent-ffmpeg');
2 | const imagemin = require('imagemin');
3 | const imageminGifsicle = require('imagemin-gifsicle');
4 | const upath = require('upath');
5 | const os = require('os');
6 |
7 | // Async function to optimize a gif using ffmpeg
8 | // http://blog.pkh.me/p/21-high-quality-gif-with-ffmpeg.html
9 | const getOptimizedGif = async (gifPath, config, errorCallback) => {
10 | const tempPalPath = upath.join(os.tmpdir(), `/live-stream-radio-gif-pal-${Date.now().toString()}.png`);
11 | const palAppliedGif = upath.join(os.tmpdir(), `/live-stream-radio-gif-with-pal-${Date.now().toString()}.gif`);
12 |
13 | const getFfmpeg = input => {
14 | let ffmpegCommand = ffmpeg();
15 | // Set our ffmpeg path if we have one
16 | if (config.ffmpeg_path) {
17 | ffmpegCommand = ffmpegCommand.setFfmpegPath(config.ffmpeg_path);
18 | }
19 |
20 | return ffmpegCommand.input(input);
21 | };
22 |
23 | // Create the gif pallete using ffmpeg
24 | await new Promise((resolve, reject) => {
25 | getFfmpeg(gifPath)
26 | // Equivalent to -vf
27 | // This sets the fps, and tells to output a gif palette
28 | .videoFilter(`palettegen=stats_mode=diff`)
29 | .outputOptions([
30 | // Override any existing file
31 | `-y`
32 | ])
33 | .on('end', resolve)
34 | .on('error', errorCallback)
35 | .save(tempPalPath);
36 | });
37 |
38 | // Optimize the gif quality using the palette
39 | // Must use .input() to ensure inputs are in the right order
40 | // https://superuser.com/questions/1199833/ffmpeg-palettegen-spits-out-a-palette-paletteuse-cant-use
41 | await new Promise((resolve, reject) => {
42 | getFfmpeg(gifPath)
43 | .input(tempPalPath)
44 | // Equivalient to -lavi or -filter_complex
45 | .complexFilter(
46 | // Scale the gif
47 | `scale=w=${config.max_gif_size}:h=${config.max_gif_size}` +
48 | // Maintain the aspect ratio from the previous scale, and decrease whichever breaks it
49 | `:force_original_aspect_ratio=decrease` +
50 | // Other cool gif optimization stuff, see linked blog post
51 | `:flags=lanczos` +
52 | ` [x]; [x][1:v] paletteuse=dither=sierra2_4a`
53 | )
54 | .outputOptions([
55 | // Set the format to gif
56 | `-f gif`,
57 | // Override any existing file
58 | `-y`
59 | ])
60 | .on('end', resolve)
61 | .on('error', errorCallback)
62 | .save(palAppliedGif);
63 | });
64 |
65 | // Generate the final gif with an optimized byte size
66 | // Using gifsicle
67 | const files = await imagemin([palAppliedGif], os.tmpdir(), {
68 | plugins: [
69 | imageminGifsicle({
70 | optimizationLevel: 3
71 | })
72 | ]
73 | });
74 |
75 | return files[0].path;
76 | };
77 |
78 | module.exports = {
79 | getOptimizedGif: getOptimizedGif
80 | };
81 |
--------------------------------------------------------------------------------
/src/stream/index.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk');
2 | const chalkLine = require('chalkline');
3 | const stream = require('./stream.js');
4 |
5 | // Save our current path
6 | let currentPath = undefined;
7 |
8 | // Save our current getConfig cuntion
9 | let currentGetConfig = undefined;
10 |
11 | // Save our current outputlocation
12 | let currentOutputLocation = undefined;
13 |
14 | // Save a reference to our ffmpegCommand
15 | let ffmpegCommandPromise = undefined;
16 |
17 | // Killing ffmpeg throws an expected error,
18 | // Thus we want to make sure we don't call our error callback if so
19 | let shouldListenForFfmpegErrors = false;
20 |
21 | // Create our calbacks for stream end and error
22 | const errorCallback = (err, stdout, stderr) => {
23 | // Check if we should respond to the error
24 | if (shouldListenForFfmpegErrors) {
25 | console.log('\n');
26 | chalkLine.red();
27 | console.log('\n');
28 | console.log(chalk.red('ffmpeg stderr:'), '\n\n', stderr);
29 | console.log(chalk.red('ffmpeg stdout:'), '\n\n', stdout);
30 | console.log(chalk.red('ffmpeg err:'), '\n\n', err);
31 | console.log('\n');
32 | console.log(`${chalk.red('ffmpeg encountered an error.')} 😨`);
33 | console.log(`Please see the stderror output above to fix the issue.`);
34 | console.log('\n');
35 |
36 | // Exit everything
37 | process.exit(1);
38 | }
39 | };
40 |
41 | const endCallback = () => {
42 | // Simply start a new stream
43 | console.log('\n');
44 | moduleExports.start();
45 | };
46 |
47 | // Create our exports
48 | const moduleExports = {
49 | start: async (path, getConfig, outputLocation) => {
50 | console.log('\n');
51 | chalkLine.white();
52 | console.log('\n');
53 | console.log(`${chalk.green('Starting stream!')} 🛠️`);
54 | console.log('\n');
55 |
56 | if (path) {
57 | currentPath = path;
58 | }
59 |
60 | if (getConfig) {
61 | currentGetConfig = getConfig;
62 | }
63 |
64 | if (outputLocation) {
65 | currentOutputLocation = outputLocation;
66 | }
67 |
68 | // Get our config, this will refresh on every song
69 | let config = await currentGetConfig();
70 |
71 | // Build our stream outputs
72 | if (!currentOutputLocation) {
73 | if (config.stream_outputs) {
74 | currentOutputLocation = config.stream_outputs;
75 | } else {
76 | if (!config.stream_url || !config.stream_key) {
77 | console.log(`${chalk.red('Missing stream_url or stream_key in your config.json !')} 😟`);
78 | console.log(chalk.red('Exiting...'));
79 | console.log('\n');
80 | process.exit(1);
81 | }
82 |
83 | let streamUrl = config.stream_url;
84 | streamUrl = streamUrl.replace('$stream_key', config.stream_key);
85 | currentOutputLocation = streamUrl;
86 | }
87 | }
88 |
89 | console.log(`${chalk.magenta('Streaming to:')} ${currentOutputLocation}`);
90 | console.log('\n');
91 |
92 | // Listen for errors again
93 | shouldListenForFfmpegErrors = true;
94 |
95 | // Start the stream again
96 | ffmpegCommandPromise = stream(currentPath, config, currentOutputLocation, endCallback, errorCallback);
97 | await ffmpegCommandPromise;
98 | },
99 | stop: async () => {
100 | console.log('\n');
101 | console.log(`${chalk.magenta('Stopping stream...')} ✋`);
102 | console.log('\n');
103 |
104 | shouldListenForFfmpegErrors = false;
105 | if (ffmpegCommandPromise) {
106 | // Get our command, its pid, and kill it.
107 | const ffmpegCommand = await ffmpegCommandPromise;
108 | const ffmpegCommandPid = ffmpegCommand.ffmpegProc.pid;
109 | ffmpegCommand.kill();
110 | ffmpegCommandPromise = undefined;
111 |
112 | // Wait until the pid is no longer running
113 | const isRunning = require('is-running');
114 | await new Promise(resolve => {
115 | const waitForPidToBeKilled = () => {
116 | if (isRunning(ffmpegCommandPid)) {
117 | setTimeout(() => {
118 | waitForPidToBeKilled();
119 | }, 250);
120 | } else {
121 | resolve();
122 | }
123 | };
124 | waitForPidToBeKilled();
125 | });
126 | }
127 |
128 | console.log('\n');
129 | console.log(`${chalk.red('Stream stopped!')} 😃`);
130 | console.log('\n');
131 | },
132 | isRunning: () => {
133 | return shouldListenForFfmpegErrors;
134 | }
135 | };
136 |
137 | // Finally our exports
138 | module.exports = moduleExports;
139 |
--------------------------------------------------------------------------------
/src/stream/overlayText.js:
--------------------------------------------------------------------------------
1 | // Overlay text for the stream
2 | const safeStrings = require('./safeStrings');
3 |
4 | const getOverlayTextString = async (path, config, typeKey, metadata) => {
5 | // Create our overlay
6 | // Note: Positions and sizes are done relative to the input video width and height
7 | // Therefore position x/y is a percentage, like CSS style.
8 | // Font size is simply just a fraction of the width
9 | let overlayTextFilterString = '';
10 | if (config[typeKey].overlay && config[typeKey].overlay.enabled) {
11 | const overlayConfigObject = config[typeKey].overlay;
12 | const overlayTextItems = [];
13 |
14 | const fontPath = `${path}${overlayConfigObject.font_path}`;
15 |
16 | // Check if we have a title option
17 | if (overlayConfigObject.title && overlayConfigObject.title.enabled) {
18 | const itemObject = overlayConfigObject.title;
19 | const safeText = safeStrings.forFilter(itemObject.text);
20 | let itemString =
21 | `drawtext=text='${safeText}'` +
22 | `:fontfile=${fontPath}` +
23 | `:fontsize=(w * ${itemObject.font_size / 300})` +
24 | `:bordercolor=${itemObject.font_border}` +
25 | `:borderw=1` +
26 | `:fontcolor=${itemObject.font_color}` +
27 | `:y=(h * ${itemObject.position_y / 100})`;
28 | if (itemObject.enable_scroll) {
29 | itemString += `:x=w-mod(max(t\\, 0) * (w + tw) / ${itemObject.font_scroll_speed}\\, (w + tw))`;
30 | } else {
31 | itemString += `:x=(w * ${itemObject.position_x / 100})`;
32 | }
33 | overlayTextItems.push(itemString);
34 | }
35 |
36 | // Check if we have an artist option
37 | if (overlayConfigObject.artist && overlayConfigObject.artist.enabled) {
38 | const itemObject = overlayConfigObject.artist;
39 | const safeText = safeStrings.forFilter(itemObject.label + metadata.common.artist);
40 | let itemString =
41 | `drawtext=text='${safeText}'` +
42 | `:fontfile=${fontPath}` +
43 | `:fontsize=(w * ${itemObject.font_size / 300})` +
44 | `:bordercolor=${itemObject.font_border}` +
45 | `:borderw=1` +
46 | `:fontcolor=${itemObject.font_color}` +
47 | `:y=(h * ${itemObject.position_y / 100})` +
48 | `:x=(w * ${itemObject.position_x / 100})`;
49 | overlayTextItems.push(itemString);
50 | }
51 |
52 | // Check if we have an album option
53 | if (overlayConfigObject.album && overlayConfigObject.album.enabled) {
54 | const itemObject = overlayConfigObject.album;
55 | const safeText = safeStrings.forFilter(itemObject.label + metadata.common.album);
56 | let itemString =
57 | `drawtext=text='${safeText}'` +
58 | `:fontfile=${fontPath}` +
59 | `:fontsize=(w * ${itemObject.font_size / 300})` +
60 | `:bordercolor=${itemObject.font_border}` +
61 | `:borderw=1` +
62 | `:fontcolor=${itemObject.font_color}` +
63 | `:y=(h * ${itemObject.position_y / 100})` +
64 | `:x=(w * ${itemObject.position_x / 100})`;
65 | overlayTextItems.push(itemString);
66 | }
67 |
68 | // Check if we have an artist option
69 | if (overlayConfigObject.song && overlayConfigObject.song.enabled) {
70 | const itemObject = overlayConfigObject.song;
71 | const safeText = safeStrings.forFilter(itemObject.label + metadata.common.title);
72 | let itemString =
73 | `drawtext=text='${safeText}'` +
74 | `:fontfile=${fontPath}` +
75 | `:fontsize=(w * ${itemObject.font_size / 300})` +
76 | `:bordercolor=${itemObject.font_border}` +
77 | `:borderw=1` +
78 | `:fontcolor=${itemObject.font_color}` +
79 | `:y=(h * ${itemObject.position_y / 100})` +
80 | `:x=(w * ${itemObject.position_x / 100})`;
81 | overlayTextItems.push(itemString);
82 | }
83 |
84 | // Add our video filter with all of our overlays
85 | overlayTextItems.forEach((item, index) => {
86 | overlayTextFilterString += `${item}`;
87 | if (index < overlayTextItems.length - 1) {
88 | overlayTextFilterString += ',';
89 | }
90 | });
91 | }
92 |
93 | // Return the filter string
94 | return overlayTextFilterString;
95 | };
96 |
97 | module.exports = getOverlayTextString;
98 |
--------------------------------------------------------------------------------
/src/stream/randomFile.js:
--------------------------------------------------------------------------------
1 | const find = require('find');
2 |
3 | // Async Function to get a random file from a path
4 | module.exports = async (extensions, path) => {
5 | // Find al of our files with the extensions
6 | let allFiles = [];
7 | extensions.forEach(extension => {
8 | allFiles = [...allFiles, ...find.fileSync(extension, path)];
9 | });
10 |
11 | // Return a random file
12 | return allFiles[Math.floor(Math.random() * allFiles.length)];
13 | };
14 |
--------------------------------------------------------------------------------
/src/stream/safeStrings.js:
--------------------------------------------------------------------------------
1 | // Function to make paths safe that are used in an input
2 | var safeStringInput = function(string) {
3 | var safeString = string;
4 |
5 | // Safen the string
6 | // \ will be \\
7 | safeString = safeString.replace(/\\/g, '\\\\');
8 |
9 | // : will be \:
10 | safeString = safeString.replace(/\:/g, '\\:');
11 |
12 | // Return safe string
13 | return safeString;
14 | };
15 |
16 | // Function to make string safe that are used in a filter
17 | var safeStringFilter = function(string) {
18 | var safeString = string;
19 |
20 | // Safen the string
21 | // \ will be \\
22 | safeString = safeString.replace(/\\/g, '\\\\');
23 |
24 | // : will be \:
25 | safeString = safeString.replace(/\:/g, '\\:');
26 |
27 | // ' will be \'
28 | safeString = safeString.replace(/\'/g, "\\'");
29 |
30 | // Return safe string
31 | return safeString;
32 | };
33 |
34 | module.exports = {
35 | forInput: safeStringInput,
36 | forFilter: safeStringFilter
37 | };
38 |
--------------------------------------------------------------------------------
/src/stream/stream.js:
--------------------------------------------------------------------------------
1 | // Get our ffmpeg
2 | const ffmpeg = require('fluent-ffmpeg');
3 | const chalk = require('chalk');
4 | const musicMetadata = require('music-metadata');
5 | const upath = require('upath');
6 | const progress = require('cli-progress');
7 |
8 | // Get our Services and helper fucntions
9 | const safeStrings = require('./safeStrings');
10 | const historyService = require('../history.service');
11 | const supportedFileTypes = require('../supportedFileTypes');
12 | const getRandomFileWithExtensionFromPath = require('./randomFile');
13 | const getOverlayTextString = require('./overlayText');
14 |
15 | // Allow pre rendering the next video if needed
16 | let nextVideo = undefined;
17 | let nextTypeKey = undefined;
18 |
19 | const getTypeKey = config => {
20 | let typeKey = 'radio';
21 | if (config.interlude.enabled) {
22 | const randomNumber = Math.random();
23 | const frequency = parseFloat(config.interlude.frequency, 10);
24 | if (randomNumber <= frequency) {
25 | typeKey = 'interlude';
26 | }
27 | }
28 |
29 | return typeKey;
30 | };
31 |
32 | const getVideo = async (path, config, typeKey, errorCallback) => {
33 | const randomVideo = await getRandomFileWithExtensionFromPath(
34 | supportedFileTypes.supportedVideoTypes,
35 | `${path}${config[typeKey].video_directory}`
36 | );
37 |
38 | // Do some optimizations to our video as we need
39 | let optimizedVideo;
40 | if (randomVideo.endsWith('.gif')) {
41 | // Optimize gif
42 | optimizedVideo = await require('./gif.js').getOptimizedGif(randomVideo, config, errorCallback);
43 | } else {
44 | optimizedVideo = randomVideo;
45 | }
46 |
47 | return {
48 | randomVideo: randomVideo,
49 | optimizedVideo: optimizedVideo
50 | };
51 | };
52 |
53 | // Function to start a stream
54 | module.exports = async (path, config, outputLocation, endCallback, errorCallback) => {
55 | // Find what type of stream we want, radio, interlude, etc...
56 | let typeKey = 'radio';
57 | if (nextTypeKey) {
58 | typeKey = nextTypeKey;
59 | nextTypeKey = undefined;
60 | } else {
61 | typeKey = getTypeKey(config);
62 | }
63 |
64 | if (typeKey !== 'radio') {
65 | console.log(chalk.magenta(`Playing an ${typeKey}...`));
66 | console.log('\n');
67 | }
68 |
69 | console.log(chalk.magenta(`Finding audio... 🎤`));
70 | console.log('\n');
71 |
72 | // Find a random song from the config directory
73 | const randomSong = await getRandomFileWithExtensionFromPath(
74 | supportedFileTypes.supportedAudioTypes,
75 | `${path}${config[typeKey].audio_directory}`
76 | );
77 |
78 | console.log(chalk.blue(`Playing the audio:`));
79 | console.log(randomSong);
80 | console.log('\n');
81 |
82 | console.log(chalk.magenta(`Finding/Optimizing video... 📺`));
83 | console.log('\n');
84 |
85 | // Get the stream video
86 | let randomVideo;
87 | let optimizedVideo;
88 | if (nextVideo) {
89 | randomVideo = nextVideo.randomVideo;
90 | optimizedVideo = nextVideo.optimizedVideo;
91 | nextVideo = undefined;
92 | } else {
93 | const videoObject = await getVideo(path, config, typeKey, errorCallback);
94 | randomVideo = videoObject.randomVideo;
95 | optimizedVideo = videoObject.optimizedVideo;
96 | }
97 |
98 | console.log(chalk.blue(`Playing the video:`));
99 | console.log(randomVideo);
100 | console.log('\n');
101 |
102 | // Get the information about the song
103 | const metadata = await musicMetadata.parseFile(randomSong, { duration: true });
104 |
105 | // Log data about the song
106 | if (metadata.common.artist) {
107 | console.log(chalk.yellow(`Artist: ${metadata.common.artist}`));
108 | }
109 | if (metadata.common.album) {
110 | console.log(chalk.yellow(`Album: ${metadata.common.album}`));
111 | }
112 | if (metadata.common.title) {
113 | console.log(chalk.yellow(`Song: ${metadata.common.title}`));
114 | }
115 | console.log(chalk.yellow(`Duration (seconds): ${Math.ceil(metadata.format.duration)}`));
116 | console.log('\n');
117 | // Log a album cover if available
118 | if (metadata.common.picture && metadata.common.picture.length > 0) {
119 | // windows is not supported by termImg
120 | // process.platform always will be win32 on windows, no matter if it is 32bit or 64bit
121 | if (process.platform != 'win32') {
122 | try {
123 | const termImg = require('term-img');
124 | termImg(metadata.common.picture[0].data, {
125 | width: '300px',
126 | height: 'auto'
127 | });
128 | console.log('\n');
129 | } catch (e) {
130 | // Do nothing, we dont need the album art
131 | }
132 | }
133 | }
134 |
135 | // Create a new command
136 | ffmpegCommand = ffmpeg();
137 |
138 | // Set our ffmpeg path if we have one
139 | if (config.ffmpeg_path) {
140 | ffmpegCommand = ffmpegCommand.setFfmpegPath(config.ffmpeg_path);
141 | }
142 |
143 | // Add the video input
144 | ffmpegCommand = ffmpegCommand.input(optimizedVideo).inputOptions([
145 | // Loop the video infinitely
146 | `-stream_loop -1`
147 | ]);
148 |
149 | // Add our audio as input
150 | ffmpegCommand = ffmpegCommand.input(randomSong).audioCodec('copy');
151 |
152 | // Add a silent input
153 | // This is useful for setting the stream -re
154 | // pace, as well as not causing any weird bugs where we only have a video
155 | // And no audio output
156 | // https://trac.ffmpeg.org/wiki/Null#anullsrc
157 | ffmpegCommand = ffmpegCommand
158 | .input('anullsrc')
159 | .audioCodec('copy')
160 | .inputOptions([
161 | // Indicate we are a virtual input
162 | `-f lavfi`,
163 | // Livestream, encode in realtime as audio comes in
164 | // https://superuser.com/questions/508560/ffmpeg-stream-a-file-with-original-playing-rate
165 | // Need the -re here as video can drastically reduce input speed, and input audio has delay
166 | `-re`
167 | ]);
168 |
169 | // Start creating our complex filter for overlaying things
170 | let complexFilterString = '';
171 |
172 | // Add silence in front of song to prevent / help with stream cutoff
173 | // Since audio is streo, we have two channels
174 | // https://ffmpeg.org/ffmpeg-filters.html#adelay
175 | // In milliseconds
176 | const delayInMilli = 3000;
177 | complexFilterString += `[1:a] adelay=${delayInMilli}|${delayInMilli} [delayedaudio]; `;
178 |
179 | // Mix our silent and song audio, se we always have an audio stream
180 | // https://ffmpeg.org/ffmpeg-filters.html#amix
181 | complexFilterString += `[delayedaudio][2:a] amix=inputs=2:duration=first:dropout_transition=3 [audiooutput]; `;
182 |
183 | // Check if we want normalized audio
184 | if (config.normalize_audio) {
185 | // Use the loudnorm filter
186 | // http://ffmpeg.org/ffmpeg-filters.html#loudnorm
187 | complexFilterString += `[audiooutput] loudnorm [audiooutput]; `;
188 | }
189 |
190 | // Okay this some weirdness. Involving fps.
191 | // So since we are realtime encoding to get the video to stream
192 | // At an apporpriate rate, this means that we encode a certain number of frames to match this
193 | // Now, let's say we have a 60fps input video, and want to output 24 fps. This is fine and work
194 | // FFMPEG will output at ~24 fps (little more or less), and video will run at correct rate.
195 | // But if you noticed the output "Current FPS" will slowly degrade to either the input
196 | // our output fps. Therefore if we had an input video at lest say 8 fps, it will slowly
197 | // Degrade to 8 fps, and then we start buffering. Thus we need to use a filter to force
198 | // The input video to be converted to the output fps to get the correct speed at which frames are rendered
199 | let configFps = '24';
200 | if (config.video_fps) {
201 | configFps = config.video_fps;
202 | }
203 | complexFilterString += `[0:v] fps=fps=${configFps}`;
204 |
205 | // Add our overlay image
206 | // This works by getting the initial filter chain applied to the first
207 | // input, aka [0:v], and giving it a label, [videowithtext].
208 | // Then using the overlay filter to combine the first input, with the video of
209 | // a second input, aka [1:v], which in this case is our image.
210 | // Lastly using scale2ref filter to ensure the image size is consistent on all
211 | // videos. And scaled the image to the video, preserving video quality
212 | if (
213 | config[typeKey].overlay &&
214 | config[typeKey].overlay.enabled &&
215 | config[typeKey].overlay.image &&
216 | config[typeKey].overlay.image.enabled
217 | ) {
218 | // Add our image input
219 | const imageObject = config[typeKey].overlay.image;
220 | const imagePath = upath.join(path, imageObject.image_path);
221 | ffmpegCommand = ffmpegCommand.input(imagePath);
222 | complexFilterString +=
223 | ` [inputvideo];` +
224 | `[3:v][inputvideo] scale2ref [scaledoverlayimage][scaledvideo];` +
225 | // Notice the overlay shortest =1, this is required to stop the video from looping infinitely
226 | `[scaledvideo][scaledoverlayimage] overlay=x=${imageObject.position_x}:y=${imageObject.position_y}`;
227 | }
228 |
229 | // Add our overlayText
230 | const overlayTextFilterString = await getOverlayTextString(path, config, typeKey, metadata);
231 | if (overlayTextFilterString) {
232 | if (complexFilterString.length > 0) {
233 | complexFilterString += `, `;
234 | }
235 | complexFilterString += `${overlayTextFilterString}`;
236 | }
237 |
238 | // Set our final output video pad
239 | complexFilterString += ` [videooutput]`;
240 |
241 | // Apply our complext filter
242 | ffmpegCommand = ffmpegCommand.complexFilter(complexFilterString);
243 |
244 | // Let's create a nice progress bar
245 | // Using the song length as the 100%, as that is when the stream should end
246 | const songTotalDuration = Math.floor(metadata.format.duration);
247 | const progressBar = new progress.Bar(
248 | {
249 | format: 'Audio Progress {bar} {percentage}% | Time Playing: {duration_formatted} |'
250 | },
251 | progress.Presets.shades_classic
252 | );
253 |
254 | // Set our event handlers
255 | ffpmepgCommand = ffmpegCommand
256 | .on('start', commandString => {
257 | console.log(' ');
258 | console.log(`${chalk.blue('Spawned Ffmpeg with command:')}`);
259 | console.log(commandString);
260 | console.log(' ');
261 |
262 | // Start our progress bar
263 | progressBar.start(songTotalDuration, 0);
264 | })
265 | .on('end', () => {
266 | progressBar.stop();
267 | if (endCallback) {
268 | endCallback();
269 | }
270 | })
271 | .on('error', (err, stdout, stderr) => {
272 | progressBar.stop();
273 |
274 | if (errorCallback) {
275 | errorCallback(err, stdout, stderr);
276 | }
277 | })
278 | .on('progress', progress => {
279 | // Get our timestamp
280 | const timestamp = progress.timemark.substring(0, 8);
281 | const splitTimestamp = timestamp.split(':');
282 | const seconds = parseInt(splitTimestamp[0], 10) * 60 * 60 + parseInt(splitTimestamp[1], 10) * 60 + parseInt(splitTimestamp[2], 10);
283 |
284 | // Set seconds onto progressBar
285 | progressBar.update(seconds);
286 | });
287 |
288 | // Get our stream duration
289 | // This is done instead of using the -shortest flag
290 | // Because of a bug where -shortest can't be used with complex audio filter
291 | // https://trac.ffmpeg.org/ticket/3789
292 | // This will give us our song duration, plus some beginning and ending padding
293 | const delayInSeconds = Math.ceil(delayInMilli / 1000);
294 | const streamDuration = delayInSeconds * 2 + Math.ceil(metadata.format.duration);
295 |
296 | // Create our ouput options
297 | // Some defaults we don't want change
298 | // Good starting point: https://wiki.archlinux.org/index.php/Streaming_to_twitch.tv
299 | const outputOptions = [
300 | `-map [videooutput]`,
301 | `-map [audiooutput]`,
302 | // Our fps from earlier
303 | `-r ${configFps}`,
304 | // Group of pictures, want to set to 2 seconds
305 | // https://trac.ffmpeg.org/wiki/EncodingForStreamingSites
306 | // https://www.addictivetips.com/ubuntu-linux-tips/stream-to-twitch-command-line-linux/
307 | // Best Explanation: https://superuser.com/questions/908280/what-is-the-correct-way-to-fix-keyframes-in-ffmpeg-for-dash
308 | `-g ${parseInt(configFps, 10) * 2}`,
309 | `-keyint_min ${configFps}`,
310 | // Stop audio once we hit the specified duration
311 | `-t ${streamDuration}`,
312 | // https://trac.ffmpeg.org/wiki/EncodingForStreamingSites
313 | `-pix_fmt yuv420p`
314 | ];
315 |
316 | if (config.video_width && config.video_height) {
317 | outputOptions.push(`-s ${config.video_width}x${config.video_height}`);
318 | } else {
319 | outputOptions.push(`-s 480x854`);
320 | }
321 |
322 | if (config.video_bit_rate) {
323 | outputOptions.push(`-b:v ${config.video_bit_rate}`);
324 | outputOptions.push(`-minrate ${config.video_bit_rate}`);
325 | outputOptions.push(`-maxrate ${config.video_bit_rate}`);
326 | }
327 |
328 | if (config.audio_bit_rate) {
329 | outputOptions.push(`-b:a ${config.audio_bit_rate}`);
330 | }
331 |
332 | if (config.audio_sample_rate) {
333 | outputOptions.push(`-ar ${config.audio_sample_rate}`);
334 | }
335 |
336 | // Set our audio codec, this can drastically affect performance
337 | if (config.audio_codec) {
338 | outputOptions.push(`-acodec ${config.audio_codec}`);
339 | } else {
340 | outputOptions.push(`-acodec aac`);
341 | }
342 |
343 | // Set our video codec, and encoder options
344 | // https://trac.ffmpeg.org/wiki/EncodingForStreamingSites
345 | if (config.video_codec) {
346 | outputOptions.push(`-vcodec ${config.video_codec}`);
347 | } else {
348 | outputOptions.push(`-vcodec libx264`);
349 | }
350 | if (config.preset) {
351 | outputOptions.push(`-preset ${config.preset}`);
352 | }
353 | if (config.bufsize) {
354 | outputOptions.push(`-bufsize ${config.bufsize}`);
355 | }
356 | if (config.crf) {
357 | outputOptions.push(`-crf ${config.crf}`);
358 | }
359 | if (config.threads) {
360 | outputOptions.push(`-threads ${config.threads}`);
361 | }
362 |
363 | // Finally, save the stream to our stream URL
364 | let singleOutputLocation = '';
365 | if (Array.isArray(outputLocation)) {
366 | singleOutputLocation = outputLocation[0];
367 | } else {
368 | singleOutputLocation = outputLocation;
369 | }
370 |
371 | // Add our output options for the stream
372 | ffmpegCommand = ffmpegCommand.outputOptions([
373 | ...outputOptions,
374 | // Set format to flv (Youtube/Twitch)
375 | `-f flv`
376 | ]);
377 |
378 | ffmpegCommand = ffmpegCommand.save(singleOutputLocation);
379 |
380 | // Start some pre-rendering
381 | const preRenderTask = async () => {
382 | nextTypeKey = getTypeKey(config);
383 | nextVideo = await getVideo(path, config, nextTypeKey, errorCallback);
384 | };
385 | preRenderTask();
386 |
387 | // Add this item to our history
388 | const historyMetadata = metadata.common;
389 | delete historyMetadata.picture;
390 | historyService.addItemToHistory({
391 | audio: {
392 | path: randomSong,
393 | metadata: historyMetadata
394 | },
395 | video: {
396 | path: randomVideo
397 | }
398 | });
399 |
400 | return ffmpegCommand;
401 | };
402 |
--------------------------------------------------------------------------------
/src/stream/usage.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | name: Usage
3 | menu: CLI
4 | route: /cli/usage
5 | ---
6 |
7 | # Usage
8 | ## Starting the stream
9 | Starts the stream.
10 |
11 | `live-stream-radio --start myStream`
12 |
13 | **Shorthand:** -s
14 |
15 | You can also ommit the --start flag.
16 |
17 | `live-stream-radio myStream`
18 |
19 | ## Help
20 | `live-stream-radio --help`
21 |
22 | **Shorthand:** -h
23 |
24 | ## Generate a project
25 | `live-stream-radio -generate myStream`
26 |
27 | **Shorthand:** -g
28 |
29 | ## Override Stream Output
30 | Overrides the stream_url/stream_key config. Useful for testing output and development.
31 |
32 | `live-stream-radio --output='rmtp://example.org'`
--------------------------------------------------------------------------------
/src/supportedFileTypes.js:
--------------------------------------------------------------------------------
1 | // List of supported file types, as regex for pathnames
2 | // Lossless video formats decode better and perform better
3 | // But gifs require pre-encoding
4 | // https://superuser.com/questions/486325/lossless-universal-video-format
5 | module.exports = {
6 | supportedAudioTypes: [/\.mp3$/, /\.flac$/, /\.wav$/],
7 | supportedVideoTypes: [/\.mov$/, /\.avi$/, /\.mkv$/, /\.webm$/, /\.mp4$/, /\.gif$/]
8 | };
9 |
--------------------------------------------------------------------------------