├── .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 | ![Galaxy Noise Radio Live Stream link](https://files.aaronthedev.com/$/zk7xg) 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 | Galaxy Noise Radio Live Stream link 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 | --------------------------------------------------------------------------------