├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ ├── push.yml │ ├── release.yml │ ├── test-coverage.yml │ └── verify.yml ├── .gitignore ├── .nvmrc ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── examples │ └── express │ ├── .gitignore │ ├── .nvmrc │ ├── index.js │ ├── package-lock.json │ └── package.json ├── example.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── error.ts ├── helpers │ ├── directoryExists.spec.ts │ ├── directoryExists.ts │ ├── playlistName.spec.ts │ ├── playlistName.ts │ ├── segmentTime.spec.ts │ ├── segmentTime.ts │ ├── sizeThreshold.spec.ts │ ├── sizeThreshold.ts │ ├── space.spec.ts │ └── space.ts ├── recorder.spec.ts ├── recorder.ts ├── types.ts ├── validators.spec.ts └── validators.ts ├── test ├── events │ ├── error.spec.ts │ ├── file_created.spec.ts │ ├── progress.spec.ts │ ├── space_full.spec.ts │ ├── start.spec.ts │ ├── started.spec.ts │ ├── stop.spec.ts │ └── stopped.spec.ts ├── helpers.ts └── process.spec.ts ├── tsconfig.eslint.json ├── tsconfig.example.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | indent_size = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [{*.yml,*.md,package.json,package-lock.json}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | example/ 4 | coverage/ 5 | docs/examples/ 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parser: '@typescript-eslint/parser', 7 | plugins: [ 8 | '@typescript-eslint', 9 | ], 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | ], 14 | parserOptions: { 15 | ecmaVersion: 2020, 16 | sourceType: 'module', 17 | project: './tsconfig.eslint.json', 18 | }, 19 | rules: { 20 | 'no-console': ['error'], 21 | 'linebreak-style': ['error', 'unix'], 22 | indent: ['error', 'tab'], 23 | quotes: ['error', 'single'], 24 | semi: ['error', 'always'], 25 | '@typescript-eslint/no-unused-vars': ['error'], 26 | }, 27 | overrides: [ 28 | { 29 | files: ['**/*.spec.ts', 'test/**/*.ts'], 30 | rules: { 31 | '@typescript-eslint/ban-ts-comment': ['off'], 32 | } 33 | } 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | labels: 13 | - "dependencies" 14 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "Publish to npm.pkg.github.com 🎁" 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version-file: ".nvmrc" 14 | registry-url: "https://npm.pkg.github.com" 15 | - run: | 16 | PR_HEAD_SHA=${{ github.event.pull_request.head.sha }} 17 | SHA=${PR_HEAD_SHA:-$GITHUB_SHA} 18 | NAME='@${{ github.repository }}' 19 | VERSION=$(cat package.json | jq -r .version)-$SHA 20 | echo $(jq ".name = \"${NAME}\"" package.json) > package.json 21 | echo $(jq ".version = \"${VERSION}\"" package.json) > package.json 22 | - run: npm ci 23 | - run: npm run build 24 | - run: npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.GHCR_TOKEN_RW }} 27 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: "Validation & build 🏭" 2 | 3 | on: 4 | push: 5 | tags-ignore: ["*"] 6 | paths-ignore: ["**.md", "LICENSE"] 7 | branches: [master] 8 | pull_request: 9 | paths-ignore: ["**.md", "LICENSE"] 10 | 11 | jobs: 12 | verify: 13 | uses: ./.github/workflows/verify.yml 14 | 15 | publish: 16 | needs: [verify] 17 | uses: ./.github/workflows/publish.yml 18 | secrets: inherit 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release 🎉" 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | verify: 9 | uses: ./.github/workflows/verify.yml 10 | 11 | publish: 12 | needs: [verify] 13 | name: Build & publish 🚀 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v1 18 | with: 19 | node-version-file: ".nvmrc" 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yml: -------------------------------------------------------------------------------- 1 | name: "Publish test coverage" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | coverage: 9 | name: Sending test coverage to Code Climate 📊 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version-file: ".nvmrc" 16 | - run: npm ci 17 | - uses: paambaati/codeclimate-action@v2.6.0 18 | env: 19 | CC_TEST_REPORTER_ID: ${{ secrets.CODE_CLIMATE_TEST_REPORTER_ID }} 20 | with: 21 | coverageCommand: npm run test:coverage 22 | coverageLocations: ${{github.workspace}}/coverage/lcov.info:lcov 23 | debug: true 24 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: "Verify 🔎" 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | lint: 8 | name: Linting 🔎 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version-file: ".nvmrc" 15 | - run: npm ci 16 | - run: npm run eslint 17 | 18 | types: 19 | name: Type checking 🔎 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version-file: ".nvmrc" 26 | - run: npm ci 27 | - run: npm run types 28 | 29 | test: 30 | name: Testing on ${{ matrix.os }} -> node@${{ matrix.node }} 🧪 31 | runs-on: ${{ matrix.os }} 32 | strategy: 33 | matrix: 34 | os: [ubuntu-latest, macos-latest, windows-latest] 35 | node: [18, 16, 14] 36 | steps: 37 | - uses: actions/checkout@v3 38 | - uses: actions/setup-node@v3 39 | with: 40 | node-version: ${{ matrix.node }} 41 | - run: npm ci 42 | - run: npm run test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Coverage directory used by tools like istanbul 9 | coverage/ 10 | 11 | # Dependency directories 12 | node_modules/ 13 | 14 | # Optional npm cache directory 15 | .npm 16 | 17 | dist/ 18 | example/ 19 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.16 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "af4jm.vscode-m3u" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project should be documented in this file. 4 | 5 | ## [2.2.0] - [Fix] Can't stop recording in a docker container 6 | 7 | **[Breaking change]** 8 | 9 | If you were rely on `playlistName` option that it was able to accept value like `$(date +%Y.%m.%d-%H.%M.%S)`, now it doesn't work. You have to prepare dynamic value somewhere in your code before you pass it into Recorder instance. But by default `playlistName` still dynamic and completely the same. So, you code should work with no changes and issues. 10 | 11 | ## [2.0.3] - Bugfix & update 12 | 13 | - [Issue #195](https://github.com/boonya/rtsp-video-recorder/issues/195) acknowledged, investigated and fixed 14 | - Dev dependencies updated 15 | - `jest.mocked` instead of `ts-jest/mocked` due to https://github.com/facebook/jest/pull/12089 16 | 17 | ## [2.0.2-beta.1] - Bugfix & update 18 | 19 | - [Issue #170](https://github.com/boonya/rtsp-video-recorder/issues/170) acknowledged, investigated and fixed 20 | - Dev dependencies updated 21 | 22 | ## [2.0.1-alpha.6] - Several bugfixes and improvements 23 | 24 | - Verify disc space on "start" as well. Recording is not going to start if space not enough. 25 | - "space_full" event occurs correctly now 26 | - unsubscribe "progress", "file_created" & "space_full" events when ffmpeg process stopped only. 27 | - Event "start" occurs before "progress" 28 | 29 | ## [2.0.0-alpha.5] - FFMPEG HLS 30 | 31 | - Simplified code 32 | - No messy asynchronous operations anymore 33 | - **.m3u8** playlist generation 34 | - Zero dependencies (except of **ffmpeg** and dev dependencies of course) 35 | - Reduced package size 36 | - node 10+ support 37 | - No space wiping, **space_wiped** event and **autoClear** option anymore `[BREAKING CHANGES]` 38 | - In case threshold has reached process just stops 39 | - **space_full** event does not expose a path anymore. Just **{threshold: Number, used: Number}** 40 | - No **segment_started** event anymore `[BREAKING CHANGES]` 41 | - **file_created** & **started** events expose relative path to playlist or video file `[BREAKING CHANGES]` 42 | - **started** event property **path** has renamed to **destination** `[BREAKING CHANGES]` 43 | 44 | ## [1.4.0-alpha.4] - Dependencies update 45 | 46 | Nothing really interesting so far. 47 | 48 | - Just updated all dependencies to their latest versions 49 | - Engines declaration supports `npm@8` as well as `npm@7` since now 50 | - Development under `node@16.13` instead of `node@15` 51 | 52 | ## [1.4.0-alpha.3] - Spaces changed in favour of tabs 53 | 54 | - Dev dependencies up to date. 55 | - node & npm versions are bumped. 56 | 57 | ## [1.4.0-alpha.2] - Audio stream included by default 58 | 59 | - `noAudio` option. By default the process is going to record audio stream into a file. But in case you don't want to, you can pass `true` to this option. Note that audio stream is encoded using ACC. 60 | 61 | - All dependencies up to date. 62 | 63 | ## [1.3.1-alpha.2] - Show errors in a message for RecorderValidationError 64 | 65 | - `RecorderValidationError` throws an errors list in addition to just a message. 66 | 67 | [2.0.2-beta.1]: https://github.com/boonya/rtsp-video-recorder/compare/2.0.1-alpha.6...2.0.2-beta.1 68 | [2.0.1-alpha.6]: https://github.com/boonya/rtsp-video-recorder/compare/2.0.0-alpha.5...2.0.1-alpha.6 69 | [2.0.0-alpha.5]: https://github.com/boonya/rtsp-video-recorder/compare/1.4.0-alpha.4...2.0.0-alpha.5 70 | [1.4.0-alpha.4]: https://github.com/boonya/rtsp-video-recorder/compare/1.4.0-alpha.3...1.4.0-alpha.4 71 | [1.4.0-alpha.3]: https://github.com/boonya/rtsp-video-recorder/compare/1.4.0-alpha.2...1.4.0-alpha.3 72 | [1.4.0-alpha.2]: https://github.com/boonya/rtsp-video-recorder/compare/1.3.1-alpha.2...1.4.0-alpha.2 73 | [1.3.1-alpha.2]: https://github.com/boonya/rtsp-video-recorder/compare/1.3.1-alpha.1...1.3.1-alpha.2 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Serhii [boonya] Buinytskyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RTSP Video Recorder 2 | 3 | ## Provides an API to record rtsp video stream as a mp4 files splitted out on separate segments 4 | 5 | - Does not depend on any other third-party packages at a production environment. 6 | - Small bundle size 7 | - Fluent Interface 8 | - Event Emitter 9 | - Very customizable 10 | 11 | [![Validation & build](https://github.com/boonya/rtsp-video-recorder/actions/workflows/push.yml/badge.svg)](https://github.com/boonya/rtsp-video-recorder/actions/workflows/push.yml) 12 | [![Release](https://github.com/boonya/rtsp-video-recorder/actions/workflows/release.yml/badge.svg)](https://github.com/boonya/rtsp-video-recorder/actions/workflows/release.yml) 13 | [![npm](https://img.shields.io/npm/v/rtsp-video-recorder)](https://www.npmjs.com/package/rtsp-video-recorder) 14 | [![Maintainability](https://img.shields.io/codeclimate/maintainability-percentage/boonya/rtsp-video-recorder?label=maintainability)](https://codeclimate.com/github/boonya/rtsp-video-recorder) 15 | [![Test Coverage](https://img.shields.io/codeclimate/coverage/boonya/rtsp-video-recorder)](https://codeclimate.com/github/boonya/rtsp-video-recorder) 16 | ![Bundle Size](https://img.shields.io/bundlephobia/min/rtsp-video-recorder) 17 | 18 | ## Precondition 19 | 20 | This library spawns `ffmpeg` as a child process, so it won't work with no `ffmpeg` installed. 21 | To do so just type: 22 | 23 | ```bash 24 | sudo apt update 25 | sudo apt install -y ffmpeg 26 | ``` 27 | 28 | If you prefer different package manager or work on different linux distr use appropriate to your system command. 29 | 30 | ## Installation 31 | 32 | Installation process of this lib as simple as it can be. Just run 33 | 34 | ```bash 35 | npm i --save rtsp-video-recorder 36 | ``` 37 | 38 | After that you can use it like on example below 39 | 40 | ## Example 41 | 42 | ### Init an instance of recorder 43 | 44 | ```ts 45 | import Recorder, { RecorderEvents } from 'rtsp-video-recorder'; 46 | 47 | const recorder = new Recorder('rtsp://username:password@host/path', '/media/Recorder', { 48 | title: 'Test Camera', 49 | }); 50 | ``` 51 | 52 | if you application is a CommonJs module you should be able do the same this way: 53 | 54 | ```js 55 | const {Recorder, RecorderEvents} = require('rtsp-video-recorder'); 56 | 57 | const recorder = new Recorder('rtsp://username:password@host/path', '/media/Recorder', { 58 | title: 'Test Camera', 59 | }); 60 | ``` 61 | 62 | ### Start recording 63 | 64 | ```ts 65 | recorder.start(); 66 | ``` 67 | 68 | ### Stop recording 69 | 70 | ```ts 71 | recorder.stop(); 72 | ``` 73 | 74 | ### If you need to know whether recording is in process or no 75 | 76 | You can execute `isRecording` method on recorder instance which returns boolean value 77 | 78 | ```ts 79 | recorder.isRecording(); 80 | ``` 81 | 82 | ### It also supports [Fluent Interface](https://en.wikipedia.org/wiki/Fluent_interface#JavaScript) 83 | 84 | ```ts 85 | import Recorder, { RecorderEvents } from 'rtsp-video-recorder'; 86 | 87 | new Recorder('rtsp://username:password@host/path', '/media/Recorder') 88 | .on(RecorderEvents.STARTED, onStarted) 89 | .on(RecorderEvents.STOPPED, onStopped) 90 | .on(RecorderEvents.FILE_CREATED, onFileCreated) 91 | .start(); 92 | ``` 93 | 94 | ## Arguments 95 | 96 | ### uri 97 | 98 | RTSP stream URI. 99 | e.g. `rtsp://username:password@host/path` 100 | 101 | ### destination 102 | 103 | Path to the directory for video records. 104 | It may be relative but better to define it in absolute manner. 105 | 106 | ## Options 107 | 108 | ### title 109 | 110 | Title of video file. Used as metadata of video file. 111 | 112 | ### playlistName 113 | 114 | The name you want your playlist file to have. 115 | 116 | By default the name is going to be a datetime string in a format `Y.m.d-H.M.S` (e.g. `2020.01.03-03.19.15`) which represents the time playlist have been created. 117 | 118 | ### filePattern 119 | 120 | File path pattern. By default it is `%Y.%m.%d/%H.%M.%S` which will be translated to e.g. `2020.01.03/03.19.15` 121 | 122 | [_Accepts C++ strftime specifiers:_](http://www.cplusplus.com/reference/ctime/strftime/) 123 | 124 | ### segmentTime 125 | 126 | Duration of one video file (in seconds). 127 | **600 seconds or 10 minutes by default** if not defined. 128 | It can be a number of seconds or string `xs`, `xm` or `xh` what means amount of **seconds**, **minutes** or **hours** respectively. 129 | 130 | ### noAudio 131 | 132 | By default the process is going to record audio stream into a file but in case you don't want to, you can pass `true` to this option. Note that audio stream is encoded using ACC. 133 | 134 | ### dirSizeThreshold 135 | 136 | In case you have this option specified you will have ability to catch `SPACE_FULL` event when threshold is reached. It can be a number of bytes or string `xM`, `xG` or `xT` what means amount of **Megabytes**, **Gigabytes** or **Terabytes** respectively. 137 | 138 | _NOTE that option does not make sense if `dirSizeThreshold` option is not specified._ 139 | 140 | ### ffmpegBinary 141 | 142 | In case you need to specify a path to ffmpeg binary you can do it using this argument. 143 | 144 | ## Events 145 | 146 | ### `start` event 147 | 148 | ```ts 149 | recorder.on(RecorderEvents.START, (payload) => { 150 | assert.equal(payload, 'programmatically'); 151 | }); 152 | ``` 153 | 154 | ### `stop` event 155 | 156 | Normal stop 157 | 158 | ```ts 159 | recorder.on(RecorderEvents.STOP, (payload) => { 160 | assert.equal(payload, 'programmatically'); 161 | }); 162 | ``` 163 | 164 | If space full 165 | 166 | ```ts 167 | recorder.on(RecorderEvents.STOP, (payload) => { 168 | assert.equal(payload, 'space_full'); 169 | }); 170 | ``` 171 | 172 | In case of other errors 173 | 174 | ```ts 175 | recorder.on(RecorderEvents.STOP, (payload) => { 176 | assert.equal(payload, 'error', Error); 177 | }); 178 | ``` 179 | 180 | ### `started` event 181 | 182 | Handler receives an object that contains options applied to the current process 183 | 184 | - Default values if no options passed. 185 | - Converted values in case of some options if passed. 186 | 187 | ```ts 188 | recorder.on(RecorderEvents.STARTED, (payload) => { 189 | assert.equal(payload, { 190 | uri: 'rtsp://username:password@host/path', 191 | destination: '/media/Recorder', 192 | playlist: 'playlist.m3u8', 193 | title: 'Test Camera', 194 | filePattern: '%Y.%m.%d/%H.%M.%S', 195 | segmentTime: 600, 196 | noAudio: false, 197 | ffmpegBinary: 'ffmpeg', 198 | }); 199 | }); 200 | ``` 201 | 202 | ### `stopped` event 203 | 204 | If stopped because of space full handler receives 0 exit code & reason message 'space_full'. 205 | 206 | ```ts 207 | recorder.on(RecorderEvents.STOPPED, (payload) => { 208 | assert.equal(payload, 0, 'space_full'); 209 | }); 210 | ``` 211 | 212 | Or if stop reason is FFMPEG process exited, handler receives an exit code of ffmpeg process and a message that FFMPEG exited. 213 | 214 | ```ts 215 | recorder.on(RecorderEvents.STOPPED, (payload) => { 216 | assert.equal(payload, 255, 'ffmpeg_exited'); 217 | }); 218 | ``` 219 | 220 | ### `file_created` event 221 | 222 | New file should be created when new segment started or in case of recording stopped. 223 | 224 | ```ts 225 | recorder.on(RecorderEvents.FILE_CREATED, (payload) => { 226 | assert.equal(payload, `2020.06.25/10.18.04.mp4`); 227 | }); 228 | ``` 229 | 230 | ### `space_full` event 231 | 232 | If no space left an event should be emitted and payload raised. 233 | 234 | There is approximation percentage which is set to 1, so when you reach out 496 you'll have `space_full` event emitted if you set your threshold e.g. 500. 235 | In other words it works based on formula `Math.ceil(used + used * APPROXIMATION_PERCENTAGE / 100) > threshold` where `threshold` is you threshold valid and `used` is amount of space used. 236 | 237 | ```ts 238 | recorder.on(RecorderEvents.SPACE_FULL, (payload) => { 239 | assert.equal(payload, { 240 | threshold: 500, 241 | used: 496, 242 | }); 243 | }); 244 | ``` 245 | 246 | ### `error` event 247 | 248 | ```ts 249 | recorder.on(RecorderEvents.ERROR, () => { 250 | /** Do what you need in case of recording error */ 251 | }); 252 | ``` 253 | 254 | ## Here you may see several examples of usage 255 | 256 | - [Express](https://github.com/boonya/rtsp-video-recorder/tree/master/docs/examples/express/) 257 | - [Meteor](https://github.com/boonya/meteor-recorder/blob/main/imports/api/recorder.js#L52) 258 | -------------------------------------------------------------------------------- /docs/examples/express/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /docs/examples/express/.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /docs/examples/express/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const {Recorder, RecorderEvents} = require('rtsp-video-recorder'); 3 | const app = express(); 4 | const PORT = 3000; 5 | 6 | try { 7 | const recorder = new Recorder('rtsp://rtsp.stream/pattern', '.', { 8 | title: 'Test video stream' 9 | }); 10 | 11 | recorder.on(RecorderEvents.FILE_CREATED, (...args) => console.log('file_created:', ...args)); 12 | recorder.on(RecorderEvents.PROGRESS, (...args) => console.log('progress:', ...args)); 13 | recorder.on(RecorderEvents.STOP, (...args) => console.log('stop:', ...args)); 14 | recorder.on(RecorderEvents.STOPPED, (...args) => console.log('stopped:', ...args)); 15 | recorder.on(RecorderEvents.START, (...args) => console.log('start:', ...args)); 16 | recorder.on(RecorderEvents.STARTED, (...args) => console.log('started:', ...args)); 17 | 18 | app.get('/', (req, res) => { 19 | res.send('Hello World!'); 20 | }); 21 | 22 | app.get('/start', (req, res) => { 23 | recorder.start(); 24 | res.send('Recording has started.'); 25 | }); 26 | 27 | app.get('/stop', (req, res) => { 28 | recorder.stop(); 29 | res.send('Recording has stopped.'); 30 | }); 31 | 32 | app.listen(PORT, () => { 33 | console.log(`Example app listening on port ${PORT}`); 34 | }); 35 | 36 | } catch (err) { 37 | console.error('Error:', err.message); 38 | } 39 | -------------------------------------------------------------------------------- /docs/examples/express/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-recorder", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "accepts": { 8 | "version": "1.3.8", 9 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 10 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 11 | "requires": { 12 | "mime-types": "~2.1.34", 13 | "negotiator": "0.6.3" 14 | } 15 | }, 16 | "array-flatten": { 17 | "version": "1.1.1", 18 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 19 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" 20 | }, 21 | "body-parser": { 22 | "version": "1.20.0", 23 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", 24 | "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", 25 | "requires": { 26 | "bytes": "3.1.2", 27 | "content-type": "~1.0.4", 28 | "debug": "2.6.9", 29 | "depd": "2.0.0", 30 | "destroy": "1.2.0", 31 | "http-errors": "2.0.0", 32 | "iconv-lite": "0.4.24", 33 | "on-finished": "2.4.1", 34 | "qs": "6.10.3", 35 | "raw-body": "2.5.1", 36 | "type-is": "~1.6.18", 37 | "unpipe": "1.0.0" 38 | } 39 | }, 40 | "bytes": { 41 | "version": "3.1.2", 42 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 43 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" 44 | }, 45 | "call-bind": { 46 | "version": "1.0.2", 47 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", 48 | "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", 49 | "requires": { 50 | "function-bind": "^1.1.1", 51 | "get-intrinsic": "^1.0.2" 52 | } 53 | }, 54 | "content-disposition": { 55 | "version": "0.5.4", 56 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 57 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 58 | "requires": { 59 | "safe-buffer": "5.2.1" 60 | } 61 | }, 62 | "content-type": { 63 | "version": "1.0.4", 64 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", 65 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" 66 | }, 67 | "cookie": { 68 | "version": "0.5.0", 69 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", 70 | "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" 71 | }, 72 | "cookie-signature": { 73 | "version": "1.0.6", 74 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 75 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" 76 | }, 77 | "debug": { 78 | "version": "2.6.9", 79 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 80 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 81 | "requires": { 82 | "ms": "2.0.0" 83 | } 84 | }, 85 | "depd": { 86 | "version": "2.0.0", 87 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 88 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 89 | }, 90 | "destroy": { 91 | "version": "1.2.0", 92 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", 93 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" 94 | }, 95 | "ee-first": { 96 | "version": "1.1.1", 97 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 98 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 99 | }, 100 | "encodeurl": { 101 | "version": "1.0.2", 102 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 103 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" 104 | }, 105 | "escape-html": { 106 | "version": "1.0.3", 107 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 108 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 109 | }, 110 | "etag": { 111 | "version": "1.8.1", 112 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 113 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" 114 | }, 115 | "express": { 116 | "version": "4.18.1", 117 | "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz", 118 | "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==", 119 | "requires": { 120 | "accepts": "~1.3.8", 121 | "array-flatten": "1.1.1", 122 | "body-parser": "1.20.0", 123 | "content-disposition": "0.5.4", 124 | "content-type": "~1.0.4", 125 | "cookie": "0.5.0", 126 | "cookie-signature": "1.0.6", 127 | "debug": "2.6.9", 128 | "depd": "2.0.0", 129 | "encodeurl": "~1.0.2", 130 | "escape-html": "~1.0.3", 131 | "etag": "~1.8.1", 132 | "finalhandler": "1.2.0", 133 | "fresh": "0.5.2", 134 | "http-errors": "2.0.0", 135 | "merge-descriptors": "1.0.1", 136 | "methods": "~1.1.2", 137 | "on-finished": "2.4.1", 138 | "parseurl": "~1.3.3", 139 | "path-to-regexp": "0.1.7", 140 | "proxy-addr": "~2.0.7", 141 | "qs": "6.10.3", 142 | "range-parser": "~1.2.1", 143 | "safe-buffer": "5.2.1", 144 | "send": "0.18.0", 145 | "serve-static": "1.15.0", 146 | "setprototypeof": "1.2.0", 147 | "statuses": "2.0.1", 148 | "type-is": "~1.6.18", 149 | "utils-merge": "1.0.1", 150 | "vary": "~1.1.2" 151 | } 152 | }, 153 | "finalhandler": { 154 | "version": "1.2.0", 155 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", 156 | "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", 157 | "requires": { 158 | "debug": "2.6.9", 159 | "encodeurl": "~1.0.2", 160 | "escape-html": "~1.0.3", 161 | "on-finished": "2.4.1", 162 | "parseurl": "~1.3.3", 163 | "statuses": "2.0.1", 164 | "unpipe": "~1.0.0" 165 | } 166 | }, 167 | "forwarded": { 168 | "version": "0.2.0", 169 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 170 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" 171 | }, 172 | "fresh": { 173 | "version": "0.5.2", 174 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 175 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" 176 | }, 177 | "function-bind": { 178 | "version": "1.1.1", 179 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", 180 | "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" 181 | }, 182 | "get-intrinsic": { 183 | "version": "1.1.2", 184 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.2.tgz", 185 | "integrity": "sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA==", 186 | "requires": { 187 | "function-bind": "^1.1.1", 188 | "has": "^1.0.3", 189 | "has-symbols": "^1.0.3" 190 | } 191 | }, 192 | "has": { 193 | "version": "1.0.3", 194 | "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", 195 | "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", 196 | "requires": { 197 | "function-bind": "^1.1.1" 198 | } 199 | }, 200 | "has-symbols": { 201 | "version": "1.0.3", 202 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", 203 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" 204 | }, 205 | "http-errors": { 206 | "version": "2.0.0", 207 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 208 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 209 | "requires": { 210 | "depd": "2.0.0", 211 | "inherits": "2.0.4", 212 | "setprototypeof": "1.2.0", 213 | "statuses": "2.0.1", 214 | "toidentifier": "1.0.1" 215 | } 216 | }, 217 | "iconv-lite": { 218 | "version": "0.4.24", 219 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 220 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 221 | "requires": { 222 | "safer-buffer": ">= 2.1.2 < 3" 223 | } 224 | }, 225 | "inherits": { 226 | "version": "2.0.4", 227 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 228 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 229 | }, 230 | "ipaddr.js": { 231 | "version": "1.9.1", 232 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 233 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 234 | }, 235 | "media-typer": { 236 | "version": "0.3.0", 237 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 238 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" 239 | }, 240 | "merge-descriptors": { 241 | "version": "1.0.1", 242 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", 243 | "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" 244 | }, 245 | "methods": { 246 | "version": "1.1.2", 247 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 248 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" 249 | }, 250 | "mime": { 251 | "version": "1.6.0", 252 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 253 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 254 | }, 255 | "mime-db": { 256 | "version": "1.52.0", 257 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 258 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 259 | }, 260 | "mime-types": { 261 | "version": "2.1.35", 262 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 263 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 264 | "requires": { 265 | "mime-db": "1.52.0" 266 | } 267 | }, 268 | "ms": { 269 | "version": "2.0.0", 270 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 271 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" 272 | }, 273 | "negotiator": { 274 | "version": "0.6.3", 275 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 276 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" 277 | }, 278 | "object-inspect": { 279 | "version": "1.12.2", 280 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", 281 | "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" 282 | }, 283 | "on-finished": { 284 | "version": "2.4.1", 285 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 286 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 287 | "requires": { 288 | "ee-first": "1.1.1" 289 | } 290 | }, 291 | "parseurl": { 292 | "version": "1.3.3", 293 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 294 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 295 | }, 296 | "path-to-regexp": { 297 | "version": "0.1.7", 298 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", 299 | "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" 300 | }, 301 | "proxy-addr": { 302 | "version": "2.0.7", 303 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 304 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 305 | "requires": { 306 | "forwarded": "0.2.0", 307 | "ipaddr.js": "1.9.1" 308 | } 309 | }, 310 | "qs": { 311 | "version": "6.10.3", 312 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", 313 | "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", 314 | "requires": { 315 | "side-channel": "^1.0.4" 316 | } 317 | }, 318 | "range-parser": { 319 | "version": "1.2.1", 320 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 321 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 322 | }, 323 | "raw-body": { 324 | "version": "2.5.1", 325 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", 326 | "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", 327 | "requires": { 328 | "bytes": "3.1.2", 329 | "http-errors": "2.0.0", 330 | "iconv-lite": "0.4.24", 331 | "unpipe": "1.0.0" 332 | } 333 | }, 334 | "rtsp-video-recorder": { 335 | "version": "2.2.0", 336 | "resolved": "https://registry.npmjs.org/rtsp-video-recorder/-/rtsp-video-recorder-2.2.0.tgz", 337 | "integrity": "sha512-46zMKmeSf9MCXPXMyhIPd5fZk7HsrMhuKLh/upxOu+lPS/NpeOxd4m5cLT7FY0hAM2d1CE27amkytmV7TM8CYw==" 338 | }, 339 | "safe-buffer": { 340 | "version": "5.2.1", 341 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 342 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 343 | }, 344 | "safer-buffer": { 345 | "version": "2.1.2", 346 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 347 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 348 | }, 349 | "send": { 350 | "version": "0.18.0", 351 | "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", 352 | "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", 353 | "requires": { 354 | "debug": "2.6.9", 355 | "depd": "2.0.0", 356 | "destroy": "1.2.0", 357 | "encodeurl": "~1.0.2", 358 | "escape-html": "~1.0.3", 359 | "etag": "~1.8.1", 360 | "fresh": "0.5.2", 361 | "http-errors": "2.0.0", 362 | "mime": "1.6.0", 363 | "ms": "2.1.3", 364 | "on-finished": "2.4.1", 365 | "range-parser": "~1.2.1", 366 | "statuses": "2.0.1" 367 | }, 368 | "dependencies": { 369 | "ms": { 370 | "version": "2.1.3", 371 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 372 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 373 | } 374 | } 375 | }, 376 | "serve-static": { 377 | "version": "1.15.0", 378 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", 379 | "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", 380 | "requires": { 381 | "encodeurl": "~1.0.2", 382 | "escape-html": "~1.0.3", 383 | "parseurl": "~1.3.3", 384 | "send": "0.18.0" 385 | } 386 | }, 387 | "setprototypeof": { 388 | "version": "1.2.0", 389 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 390 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 391 | }, 392 | "side-channel": { 393 | "version": "1.0.4", 394 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", 395 | "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", 396 | "requires": { 397 | "call-bind": "^1.0.0", 398 | "get-intrinsic": "^1.0.2", 399 | "object-inspect": "^1.9.0" 400 | } 401 | }, 402 | "statuses": { 403 | "version": "2.0.1", 404 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 405 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" 406 | }, 407 | "toidentifier": { 408 | "version": "1.0.1", 409 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 410 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 411 | }, 412 | "type-is": { 413 | "version": "1.6.18", 414 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 415 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 416 | "requires": { 417 | "media-typer": "0.3.0", 418 | "mime-types": "~2.1.24" 419 | } 420 | }, 421 | "unpipe": { 422 | "version": "1.0.0", 423 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 424 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" 425 | }, 426 | "utils-merge": { 427 | "version": "1.0.1", 428 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 429 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" 430 | }, 431 | "vary": { 432 | "version": "1.1.2", 433 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 434 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" 435 | } 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /docs/examples/express/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-recorder", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "author": "Serhii [boonya] Buinytskyi", 10 | "license": "ISC", 11 | "dependencies": { 12 | "express": "^4.18.1", 13 | "rtsp-video-recorder": "^2.2.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /example.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import readline from 'readline'; 3 | import Recorder, { RecorderEvents } from './src/recorder'; 4 | 5 | const log = (event: string) => (...args: unknown[]) => { 6 | console.log(new Date().toString()); 7 | console.log(`Event "${event}": `, ...args); 8 | console.log(); 9 | }; 10 | 11 | function logProgress(...args: unknown[]) { 12 | return log(RecorderEvents.PROGRESS)(...args); 13 | } 14 | 15 | readline.emitKeypressEvents(process.stdin); 16 | if (process.stdin.isTTY) { 17 | process.stdin.setRawMode(true); 18 | } 19 | 20 | try { 21 | const { 22 | SOURCE, 23 | IP, 24 | TITLE, 25 | SEGMENT_TIME, 26 | THRESHOLD, 27 | FILE_PATTERN, 28 | NO_AUDIO, 29 | DESTINATION, 30 | SHOW_PROGRESS, 31 | PLAYLIST_NAME, 32 | FFMPEG_BINARY, 33 | } = process.env; 34 | 35 | if (!DESTINATION || (!SOURCE && !IP) || (SOURCE && IP)) { 36 | console.warn('Error: Please specify SOURCE or IP + DESTINATION.'); 37 | process.exit(1); 38 | } 39 | 40 | const source = SOURCE || `rtsp://${IP}:554/user=admin_password=tlJwpbo6_channel=1_stream=1.sdp?real_stream`; 41 | 42 | const title = TITLE || 'Example cam'; 43 | const safeTitle = title 44 | .replace(/[:]+/ug, '_') 45 | .replace(/_+/ug, '_'); 46 | const segmentTime = SEGMENT_TIME || '10m'; 47 | const dirSizeThreshold = THRESHOLD || '500M'; 48 | const noAudio = NO_AUDIO === 'true' ? true : false; 49 | const filePattern = FILE_PATTERN || `${safeTitle}-%Y.%m.%d/%H.%M.%S`; 50 | const playlistName = PLAYLIST_NAME || safeTitle; 51 | 52 | const recorder = new Recorder(source, DESTINATION, { 53 | title, 54 | segmentTime, 55 | filePattern, 56 | playlistName, 57 | dirSizeThreshold, 58 | noAudio, 59 | ffmpegBinary: FFMPEG_BINARY, 60 | }); 61 | 62 | if (SHOW_PROGRESS) { 63 | recorder.on(RecorderEvents.PROGRESS, logProgress); 64 | } 65 | else { 66 | recorder.on(RecorderEvents.PROGRESS, logProgress) 67 | .on(RecorderEvents.STARTED, () => { 68 | recorder.removeListener(RecorderEvents.PROGRESS, logProgress); 69 | }) 70 | .on(RecorderEvents.STOP, () => { 71 | recorder.on(RecorderEvents.PROGRESS, logProgress); 72 | }); 73 | } 74 | 75 | recorder 76 | .on(RecorderEvents.START, log(RecorderEvents.START)) 77 | .on(RecorderEvents.STARTED, log(RecorderEvents.STARTED)) 78 | .on(RecorderEvents.STOP, log(RecorderEvents.STOP)) 79 | .on(RecorderEvents.STOPPED, log(RecorderEvents.STOPPED)) 80 | .on(RecorderEvents.ERROR, log(RecorderEvents.ERROR)) 81 | .on(RecorderEvents.FILE_CREATED, log(RecorderEvents.FILE_CREATED)) 82 | .on(RecorderEvents.SPACE_FULL, log(RecorderEvents.SPACE_FULL)) 83 | .start(); 84 | 85 | process.stdin.on('keypress', (_, key) => { 86 | if (key.ctrl && key.name === 'c') { 87 | if (recorder.isRecording()) { 88 | recorder 89 | .on(RecorderEvents.STOPPED, () => { 90 | setTimeout(() => { 91 | console.log('Gracefully stopped.'); 92 | process.exit(); 93 | }, 2000); 94 | }) 95 | .stop(); 96 | } else { 97 | process.exit(); 98 | } 99 | } else if (key.name === 'space') { 100 | if (recorder.isRecording()) { 101 | recorder.stop(); 102 | } else { 103 | recorder.start(); 104 | } 105 | } 106 | }); 107 | console.log('Press "space" to start/stop recording, "ctrl + c" to stop a process.'); 108 | console.log(); 109 | } catch (err) { 110 | console.error(err); 111 | process.exit(1); 112 | } 113 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['src', 'test'], 5 | clearMocks: true, 6 | coverageDirectory: './coverage', 7 | coverageReporters: ['text-summary', 'html', 'lcov'], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtsp-video-recorder", 3 | "version": "2.2.0", 4 | "description": "Provide an API to record rtsp video stream to filesystem.", 5 | "main": "dist/recorder.js", 6 | "types": "dist/recorder.d.ts", 7 | "files": [ 8 | "dist/*" 9 | ], 10 | "repository": "https://github.com/boonya/rtsp-video-recorder", 11 | "engines": { 12 | "npm": ">=7", 13 | "node": ">=12" 14 | }, 15 | "scripts": { 16 | "example": "tsc -p tsconfig.example.json && node example/example.js", 17 | "types": "tsc --noemit", 18 | "eslint": "eslint .", 19 | "lint": "npm run types && npm run eslint --", 20 | "test": "jest", 21 | "test:coverage": "jest --coverage", 22 | "build": "tsc" 23 | }, 24 | "keywords": [ 25 | "rtsp", 26 | "video", 27 | "recorder", 28 | "stream", 29 | "webcam" 30 | ], 31 | "author": "Serhii [boonya] Buinytskyi", 32 | "license": "MIT", 33 | "devDependencies": { 34 | "@tsconfig/node18": "^2.0.1", 35 | "@types/jest": "^29.5.2", 36 | "@types/node": "^18.16.18", 37 | "@typescript-eslint/eslint-plugin": "^5.60.0", 38 | "@typescript-eslint/parser": "^5.60.0", 39 | "eslint": "^8.43.0", 40 | "jest": "^29.5.0", 41 | "ts-jest": "^29.1.0", 42 | "typescript": "^5.1.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | export class RecorderError extends Error { 2 | constructor (message?: string) { 3 | super(message); 4 | Object.setPrototypeOf(this, RecorderError.prototype); 5 | } 6 | } 7 | 8 | export class RecorderValidationError extends RecorderError { 9 | constructor (message: string, public errors: string[] = []) { 10 | super(errors.length ? `${message}: ${errors.join('; ')}` : message); 11 | Object.setPrototypeOf(this, RecorderValidationError.prototype); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/directoryExists.spec.ts: -------------------------------------------------------------------------------- 1 | import directoryExists from './directoryExists'; 2 | import fs from 'fs'; 3 | 4 | jest.mock('fs'); 5 | 6 | test('exists', () => { 7 | // @ts-ignore 8 | jest.mocked(fs).lstatSync.mockReturnValue({ isDirectory: () => true }); 9 | 10 | expect(directoryExists('path')).toBeTruthy(); 11 | }); 12 | 13 | test('does not exist', () => { 14 | jest.mocked(fs).lstatSync.mockImplementation(() => { 15 | throw new Error('no such file or directory'); 16 | }); 17 | 18 | expect(directoryExists('path')).toBeFalsy(); 19 | }); 20 | 21 | test('not a directory', () => { 22 | // @ts-ignore 23 | jest.mocked(fs).lstatSync.mockReturnValue({ isDirectory: () => false }); 24 | 25 | expect(() => directoryExists('path')).toThrowError('path exists but it is not a directory.'); 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers/directoryExists.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | export default function directoryExists(path: string) { 4 | try { 5 | const stats = fs.lstatSync(path); 6 | if (!stats.isDirectory()) { 7 | throw new TypeError(`${path} exists but it is not a directory.`); 8 | } 9 | return true; 10 | } catch (err) { 11 | if (err instanceof TypeError) { 12 | throw err; 13 | } 14 | return false; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/playlistName.spec.ts: -------------------------------------------------------------------------------- 1 | import playlistName from './playlistName'; 2 | 3 | test('should return current date based name.', () => { 4 | jest.useFakeTimers().setSystemTime(new Date('Feb 24 2022 04:45:00').getTime()); 5 | 6 | const result = playlistName(); 7 | 8 | expect(result).toBe('2022.02.24-04.45.00'); 9 | }); 10 | 11 | test('should return custom name.', () => { 12 | const result = playlistName('custom - name : і Colon'); 13 | 14 | expect(result).toBe('custom - name _ і Colon'); 15 | }); 16 | -------------------------------------------------------------------------------- /src/helpers/playlistName.ts: -------------------------------------------------------------------------------- 1 | export default function playlistName(customValue?: string) { 2 | if (customValue) { 3 | return customValue 4 | .replace(/[:]+/ug, '_') 5 | .replace(/_+/ug, '_'); 6 | } 7 | 8 | const now = new Date(); 9 | const [date] = now.toISOString().split('T'); 10 | const [time] = now.toTimeString().split(' '); 11 | 12 | return [ 13 | date.replace(/-/ug, '.'), 14 | time.replace(/:/ug, '.'), 15 | ].join('-'); 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/segmentTime.spec.ts: -------------------------------------------------------------------------------- 1 | import segmentTime from './segmentTime'; 2 | 3 | test('should return the same as an input.', () => { 4 | [1, 1024, 65324, 200].forEach((input) => { 5 | expect(segmentTime(input)).toEqual(input); 6 | }); 7 | }); 8 | 9 | test('should transform 1 minute string to 60 seconds number.', () => { 10 | expect(segmentTime('1m')).toEqual(60); 11 | }); 12 | -------------------------------------------------------------------------------- /src/helpers/segmentTime.ts: -------------------------------------------------------------------------------- 1 | import { DurationFactor, SegmentTimeOption } from '../types'; 2 | 3 | const SEGMENT_TIME_PATTERN = /^(\d+)(s|m|h)?$/; 4 | 5 | export default function transformSegmentTime(value: SegmentTimeOption) { 6 | if (typeof value === 'number') return value; 7 | const [operand, factor] = matchSegmentTime(value); 8 | return getDuration(operand, factor); 9 | } 10 | 11 | function matchSegmentTime(value: string): [number, DurationFactor] { 12 | const match = value.match(SEGMENT_TIME_PATTERN); 13 | if (!match) { 14 | throw new Error(`segmentTime value has to match to pattern ${SEGMENT_TIME_PATTERN.toString()}.`); 15 | } 16 | const operand = Number(match[1]); 17 | if (!operand) { 18 | throw new Error('segmentTime value has to be more than zero.'); 19 | } 20 | 21 | const factor = match[2] as DurationFactor; 22 | return [operand, factor]; 23 | } 24 | 25 | /** 26 | * @returns seconds 27 | */ 28 | function getDuration(operand: number, factor: DurationFactor): number { 29 | switch (factor) { 30 | case DurationFactor.Minutes: 31 | return operand * 60; 32 | case DurationFactor.Hours: 33 | return operand * Math.pow(60, 2); 34 | case DurationFactor.Seconds: 35 | default: 36 | return operand; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/helpers/sizeThreshold.spec.ts: -------------------------------------------------------------------------------- 1 | import sizeThreshold from './sizeThreshold'; 2 | 3 | test('should return the same as an input.', () => { 4 | [1, 1024, 65324, 200].forEach((input) => { 5 | expect(sizeThreshold(input)).toEqual(input); 6 | }); 7 | }); 8 | 9 | test('should return 1 Gig Bytes in bytes.', () => { 10 | expect(sizeThreshold('1G')).toEqual(Math.pow(1024, 3)); 11 | }); 12 | -------------------------------------------------------------------------------- /src/helpers/sizeThreshold.ts: -------------------------------------------------------------------------------- 1 | import { BytesFactor, DirSizeThresholdOption } from '../types'; 2 | 3 | const DIR_SIZE_THRESHOLD_PATTERN = /^(\d+)(M|G|T)?$/; 4 | 5 | export default function transformDirSizeThreshold(value: DirSizeThresholdOption) { 6 | if (typeof value === 'number') return value; 7 | const [operand, factor] = matchDirSizeThreshold(value); 8 | return getBytesSize(operand, factor); 9 | } 10 | 11 | function matchDirSizeThreshold(value: string): [number, BytesFactor] { 12 | const match = value.match(DIR_SIZE_THRESHOLD_PATTERN); 13 | if (!match) { 14 | throw new Error(`dirSizeThreshold value has to match to pattern ${DIR_SIZE_THRESHOLD_PATTERN.toString()}.`); 15 | } 16 | const operand = Number(match[1]); 17 | if (!operand) { 18 | throw new Error('dirSizeThreshold value has to be more than zero.'); 19 | } 20 | 21 | const factor = match[2] as BytesFactor; 22 | return [operand, factor]; 23 | } 24 | 25 | /** 26 | * @returns bytes 27 | */ 28 | function getBytesSize(operand: number, factor: BytesFactor): number { 29 | switch (factor) { 30 | case BytesFactor.Gigabytes: 31 | return operand * Math.pow(1024, 3); 32 | case BytesFactor.Terabytes: 33 | return operand * Math.pow(1024, 4); 34 | case BytesFactor.Megabytes: 35 | default: 36 | return operand * Math.pow(1024, 2); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/helpers/space.spec.ts: -------------------------------------------------------------------------------- 1 | import space from './space'; 2 | import fs from 'fs'; 3 | 4 | jest.mock('fs'); 5 | jest.mock('path'); 6 | 7 | test('should return directory size in bytes', () => { 8 | jest.mocked(fs).readdirSync.mockReturnValue(new Array(3).fill(0)); 9 | // @ts-ignore 10 | jest.mocked(fs).statSync.mockReturnValue({ isDirectory: () => false, size: 3 }); 11 | 12 | const size = space(''); 13 | 14 | expect(size).toEqual(9); 15 | }); 16 | -------------------------------------------------------------------------------- /src/helpers/space.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import pathApi from 'path'; 3 | 4 | /** 5 | * @returns bytes 6 | */ 7 | export default function dirSize(path: string) { 8 | return getDirListing(path) 9 | .map((item) => fs.statSync(item).size) 10 | .reduce((acc, size) => acc + size, 0); 11 | } 12 | 13 | function getDirListing(dir: string): string[] { 14 | return fs.readdirSync(dir) 15 | .map((item) => { 16 | const path = pathApi.join(dir, item); 17 | if (fs.statSync(path).isDirectory()) { 18 | return getDirListing(path); 19 | } 20 | return path; 21 | }) 22 | .reduce((acc, i) => { 23 | if (Array.isArray(i)) { 24 | return [...acc, ...i]; 25 | } 26 | return [...acc, i]; 27 | }, []); 28 | } 29 | -------------------------------------------------------------------------------- /src/recorder.spec.ts: -------------------------------------------------------------------------------- 1 | import { verifyAllOptions } from './validators'; 2 | import { mockSpawnProcess, URI, DESTINATION } from '../test/helpers'; 3 | import Recorder, { RecorderValidationError } from './recorder'; 4 | import playlistName from './helpers/playlistName'; 5 | 6 | jest.mock('./validators'); 7 | jest.mock('./helpers/playlistName'); 8 | 9 | beforeEach(() => { 10 | jest.mocked(verifyAllOptions).mockReturnValue([]); 11 | mockSpawnProcess(); 12 | jest.mocked(playlistName).mockReturnValue('playlist'); 13 | }); 14 | 15 | test('should throw RecorderValidationError if validation failed', () => { 16 | jest.mocked(verifyAllOptions).mockReturnValue([ 17 | 'Any validation error message', 18 | 'One more validation error message', 19 | ]); 20 | 21 | expect(() => new Recorder(URI, DESTINATION)).toThrowError(new RecorderValidationError('Options invalid', [ 22 | 'Any validation error message', 23 | 'One more validation error message', 24 | ])); 25 | }); 26 | 27 | describe('isRecording', () => { 28 | test('should return true if process started', async () => { 29 | const recorder = new Recorder(URI, DESTINATION) 30 | .start(); 31 | 32 | // We have to wait next tick 33 | await Promise.resolve(true); 34 | 35 | const isRecording = recorder.isRecording(); 36 | 37 | expect(isRecording).toBe(true); 38 | }); 39 | 40 | test('should return false if process not started', async () => { 41 | const recorder = new Recorder(URI, DESTINATION); 42 | 43 | // We have to wait next tick 44 | await Promise.resolve(true); 45 | 46 | const isRecording = recorder.isRecording(); 47 | 48 | expect(isRecording).toBe(false); 49 | }); 50 | 51 | test('should return false if process started & then stopped', () => { 52 | const isRecording = new Recorder(URI, DESTINATION) 53 | .start() 54 | .stop() 55 | .isRecording(); 56 | 57 | expect(isRecording).toBe(false); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/recorder.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; 2 | import { EventEmitter } from 'events'; 3 | import { IRecorder, Options, Events, EventCallback } from './types'; 4 | import { RecorderError, RecorderValidationError } from './error'; 5 | import { verifyAllOptions } from './validators'; 6 | import dirSize from './helpers/space'; 7 | import playlistName from './helpers/playlistName'; 8 | import transformDirSizeThreshold from './helpers/sizeThreshold'; 9 | import transformSegmentTime from './helpers/segmentTime'; 10 | 11 | export { Recorder, Events as RecorderEvents, RecorderError, RecorderValidationError }; 12 | export type { IRecorder }; 13 | 14 | const APPROXIMATION_PERCENTAGE = 1; 15 | 16 | export default class Recorder implements IRecorder { 17 | private title?: string; 18 | private ffmpegBinary = 'ffmpeg'; 19 | 20 | /** 21 | * Here you can use bash 22 | */ 23 | private playlistName?: string; 24 | 25 | /** 26 | * @READ: http://www.cplusplus.com/reference/ctime/strftime/ 27 | */ 28 | private filePattern = '%Y.%m.%d/%H.%M.%S'; 29 | 30 | private segmentTime = 600; // 10 minutes or 600 seconds 31 | 32 | private dirSizeThreshold?: number; // bytes 33 | 34 | private noAudio: boolean; 35 | 36 | private process: ChildProcessWithoutNullStreams | null = null; 37 | private eventEmitter: EventEmitter; 38 | 39 | constructor(private uri: string, private destination: string, options: Options = {}) { 40 | const errors = verifyAllOptions(destination, options); 41 | if (errors.length) { 42 | throw new RecorderValidationError('Options invalid', errors); 43 | } 44 | 45 | this.title = options.title; 46 | this.ffmpegBinary = options.ffmpegBinary || this.ffmpegBinary; 47 | this.playlistName = playlistName(options.playlistName); 48 | this.filePattern = (options.filePattern || this.filePattern) 49 | .replace(/[\s:]+/gu, '_') 50 | .replace(/_+/ug, '_'); 51 | 52 | this.segmentTime = options.segmentTime 53 | ? transformSegmentTime(options.segmentTime) 54 | : this.segmentTime; 55 | 56 | this.dirSizeThreshold = options.dirSizeThreshold 57 | ? transformDirSizeThreshold(options.dirSizeThreshold) 58 | : undefined; 59 | 60 | this.noAudio = options.noAudio || false; 61 | 62 | this.eventEmitter = new EventEmitter(); 63 | 64 | this.on(Events.START, this.startRecord); 65 | this.on(Events.STOP, this.stopRecord); 66 | } 67 | 68 | public start = () => { 69 | this.eventEmitter.emit(Events.START, 'programmatically'); 70 | return this; 71 | }; 72 | 73 | public stop = () => { 74 | this.eventEmitter.emit(Events.STOP, 'programmatically'); 75 | return this; 76 | }; 77 | 78 | public on = (event: Events, callback: EventCallback) => { 79 | this.eventEmitter.on(event, callback); 80 | return this; 81 | }; 82 | 83 | public removeListener = (event: Events, callback: EventCallback) => { 84 | this.eventEmitter.removeListener(event, callback); 85 | return this; 86 | }; 87 | 88 | public isRecording = () => Boolean(this.process); 89 | 90 | private startRecord = async () => { 91 | try { 92 | // We have to wait next tick 93 | await Promise.resolve(true); 94 | 95 | if (this.process) { 96 | throw new RecorderError('Process already spawned.'); 97 | } 98 | 99 | if (!this.isSpaceEnough()) { 100 | this.eventEmitter.emit(Events.STOPPED, 0, 'space_full'); 101 | return; 102 | } 103 | 104 | this.on(Events.PROGRESS, this.onProgress) 105 | .on(Events.FILE_CREATED, this.isSpaceEnough) 106 | .on(Events.SPACE_FULL, this.onSpaceFull) 107 | .on(Events.STOPPED, this.onStopped); 108 | 109 | this.process = spawn(this.ffmpegBinary, 110 | [ 111 | '-rtsp_transport', 'tcp', 112 | '-i', this.uri, 113 | '-reset_timestamps', '1', 114 | ...(this.title ? ['-metadata', `title="${this.title}"`] : []), 115 | ...(this.noAudio ? ['-an'] : ['-c:a', 'aac']), 116 | '-strftime', '1', 117 | '-strftime_mkdir', '1', 118 | '-hls_time', String(this.segmentTime), 119 | '-hls_list_size', '0', 120 | '-hls_segment_filename', `${this.filePattern}.mp4`, 121 | `./${this.playlistName}.m3u8`, 122 | ], 123 | { 124 | detached: false, 125 | cwd: this.destination, 126 | }, 127 | ); 128 | 129 | this.process.stderr.on('data', (buffer: Buffer) => { 130 | const message = buffer.toString(); 131 | this.eventEmitter.emit(Events.PROGRESS, message); 132 | }); 133 | 134 | this.process.on('error', (error: string) => { 135 | this.eventEmitter.emit(Events.ERROR, new RecorderError(error)); 136 | }); 137 | 138 | this.process.on('close', (code: string) => { 139 | this.eventEmitter.emit(Events.STOPPED, code, 'ffmpeg_exited'); 140 | }); 141 | } catch (err) { 142 | this.eventEmitter.emit(Events.ERROR, err); 143 | } 144 | }; 145 | 146 | private stopRecord = async () => { 147 | // We have to wait next tick 148 | await Promise.resolve(true); 149 | 150 | if (!this.process) { 151 | this.eventEmitter.emit(Events.ERROR, new RecorderError('No process spawned.')); 152 | return; 153 | } 154 | this.process.kill(); 155 | }; 156 | 157 | private matchStarted = (message: string) => { 158 | const pattern = new RegExp('Output #0, hls, to \'./(?(:?.+).m3u8)\':'); 159 | return message.match(pattern)?.groups?.file; 160 | }; 161 | 162 | private matchFileCreated = (message: string) => { 163 | const pattern = new RegExp('Opening \'(?.+)\' for writing'); 164 | const file = message.match(pattern)?.groups?.file || false; 165 | const segment = file && !file.match(/\.m3u8\.tmp$/u); 166 | return segment ? file : undefined; 167 | }; 168 | 169 | private onProgress = (message: string) => { 170 | const playlist = this.matchStarted(message); 171 | if (playlist) { 172 | this.eventEmitter.emit(Events.STARTED, { 173 | uri: this.uri, 174 | destination: this.destination, 175 | playlist, 176 | title: this.title, 177 | filePattern: this.filePattern, 178 | segmentTime: this.segmentTime, 179 | dirSizeThreshold: this.dirSizeThreshold, 180 | noAudio: this.noAudio, 181 | ffmpegBinary: this.ffmpegBinary, 182 | }); 183 | } 184 | 185 | const file = this.matchFileCreated(message); 186 | if (file) { 187 | this.eventEmitter.emit(Events.FILE_CREATED, file); 188 | } 189 | }; 190 | 191 | private isSpaceEnough = () => { 192 | try { 193 | if (!this.dirSizeThreshold) { 194 | return true; 195 | } 196 | const used = dirSize(this.destination); 197 | const enough = Math.ceil(used + used * APPROXIMATION_PERCENTAGE / 100) < this.dirSizeThreshold; 198 | if (enough) { 199 | return true; 200 | } 201 | this.eventEmitter.emit(Events.SPACE_FULL, { 202 | threshold: this.dirSizeThreshold, 203 | used, 204 | }); 205 | return false; 206 | } catch (err) { 207 | this.eventEmitter.emit(Events.ERROR, err); 208 | } 209 | return true; 210 | }; 211 | 212 | private onSpaceFull = () => { 213 | this.eventEmitter.emit(Events.STOP, 'space_full'); 214 | }; 215 | 216 | private onStopped = () => { 217 | this.eventEmitter.removeListener(Events.PROGRESS, this.onProgress); 218 | this.eventEmitter.removeListener(Events.FILE_CREATED, this.isSpaceEnough); 219 | this.eventEmitter.removeListener(Events.SPACE_FULL, this.onSpaceFull); 220 | this.process = null; 221 | }; 222 | } 223 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type SegmentTimeOption = number | string; 2 | export type DirSizeThresholdOption = number | string; 3 | 4 | export type Options = Partial<{ 5 | title: string; 6 | playlistName: string; 7 | filePattern: string; 8 | segmentTime: SegmentTimeOption; 9 | dirSizeThreshold: DirSizeThresholdOption; 10 | ffmpegBinary: string; 11 | noAudio: boolean; 12 | }>; 13 | 14 | export type EventCallback = (...args: any[]) => void; 15 | 16 | export enum Events { 17 | START = 'start', 18 | STARTED = 'started', 19 | STOP = 'stop', 20 | STOPPED = 'stopped', 21 | ERROR = 'error', 22 | PROGRESS = 'progress', 23 | FILE_CREATED = 'file_created', 24 | SPACE_FULL = 'space_full', 25 | } 26 | 27 | export interface IRecorder { 28 | start: () => this; 29 | stop: () => this; 30 | on: (event: Events, callback: EventCallback) => this; 31 | isRecording: () => boolean; 32 | } 33 | 34 | export enum BytesFactor { 35 | Megabytes = 'M', 36 | Gigabytes = 'G', 37 | Terabytes = 'T', 38 | } 39 | 40 | export enum DurationFactor { 41 | Seconds = 's', 42 | Minutes = 'm', 43 | Hours = 'h', 44 | } 45 | -------------------------------------------------------------------------------- /src/validators.spec.ts: -------------------------------------------------------------------------------- 1 | import pathApi from 'path'; 2 | import { 3 | verifyPath, 4 | verifySegmentTime, 5 | verifyDirSizeThreshold, 6 | verifyDirSizeThresholdMinimum, 7 | verifySegmentTimeMinimum, 8 | verifyAllOptions, 9 | } from './validators'; 10 | 11 | describe('verifyPath', () => { 12 | test('Valid', () => { 13 | expect(verifyPath('.')).toBeFalsy(); 14 | expect(verifyPath(__dirname)).toBeFalsy(); 15 | }); 16 | 17 | test('Invalid', () => { 18 | const path = pathApi.normalize(`${pathApi.dirname(__dirname)}/unexistant-dir`); 19 | expect(verifyPath('./unexistant-dir')).toEqual(`${path} is not a directory`); 20 | expect(verifyPath(__filename)).toEqual(`${__filename} exists but it is not a directory.`); 21 | }); 22 | }); 23 | 24 | describe('verifySegmentTime', () => { 25 | test('Valid', () => { 26 | expect(verifySegmentTime(20)).toBeFalsy(); 27 | expect(verifySegmentTime('20s')).toBeFalsy(); 28 | expect(verifySegmentTime('10m')).toBeFalsy(); 29 | expect(verifySegmentTime('1h')).toBeFalsy(); 30 | }); 31 | 32 | test('Invalid', () => { 33 | expect(verifySegmentTime(1)).toEqual('There is no sense to set duration value to less than 15 seconds.'); 34 | expect(verifySegmentTime('1s')).toEqual('There is no sense to set duration value to less than 15 seconds.'); 35 | expect(verifySegmentTime('0.1m')).toEqual('segmentTime value has to match to pattern /^(\\d+)(s|m|h)?$/.'); 36 | expect(verifySegmentTime('invalid value')).toEqual('segmentTime value has to match to pattern /^(\\d+)(s|m|h)?$/.'); 37 | }); 38 | }); 39 | 40 | describe('verifyDirSizeThreshold', () => { 41 | test('Valid', () => { 42 | expect(verifyDirSizeThreshold(200 * Math.pow(1024, 2))).toBeFalsy(); 43 | expect(verifyDirSizeThreshold('200M')).toBeFalsy(); 44 | expect(verifyDirSizeThreshold('1G')).toBeFalsy(); 45 | expect(verifyDirSizeThreshold('1T')).toBeFalsy(); 46 | }); 47 | 48 | test('Invalid', () => { 49 | expect(verifyDirSizeThreshold(199 * Math.pow(1024, 2))).toEqual('There is no sense to set dirSizeThreshold value to less that 200 MB.'); 50 | expect(verifyDirSizeThreshold('199M')).toEqual('There is no sense to set dirSizeThreshold value to less that 200 MB.'); 51 | expect(verifyDirSizeThreshold('0.1T')).toEqual('dirSizeThreshold value has to match to pattern /^(\\d+)(M|G|T)?$/.'); 52 | expect(verifyDirSizeThreshold('invalid value')).toEqual('dirSizeThreshold value has to match to pattern /^(\\d+)(M|G|T)?$/.'); 53 | }); 54 | }); 55 | 56 | test('verifyDirSizeThresholdMinimum', () => { 57 | expect(verifyDirSizeThresholdMinimum(200 * Math.pow(1024, 2))).toBeFalsy(); 58 | expect(verifyDirSizeThresholdMinimum(199 * Math.pow(1024, 2))) 59 | .toEqual('There is no sense to set dirSizeThreshold value to less that 200 MB.'); 60 | }); 61 | 62 | test('verifySegmentTimeMinimum', () => { 63 | expect(verifySegmentTimeMinimum(15)).toBeFalsy(); 64 | expect(verifySegmentTimeMinimum(14)) 65 | .toEqual('There is no sense to set duration value to less than 15 seconds.'); 66 | }); 67 | 68 | test('verifyAllOptions', () => { 69 | expect(verifyAllOptions(__filename, { segmentTime: 1, dirSizeThreshold: 1 })).toEqual([ 70 | `${__filename} exists but it is not a directory.`, 71 | 'There is no sense to set duration value to less than 15 seconds.', 72 | 'There is no sense to set dirSizeThreshold value to less that 200 MB.', 73 | ]); 74 | }); 75 | -------------------------------------------------------------------------------- /src/validators.ts: -------------------------------------------------------------------------------- 1 | import { Options, SegmentTimeOption, DirSizeThresholdOption } from './types'; 2 | import directoryExists from './helpers/directoryExists'; 3 | import pathApi from 'path'; 4 | import transformDirSizeThreshold from './helpers/sizeThreshold'; 5 | import transformSegmentTime from './helpers/segmentTime'; 6 | 7 | function getErrorMessage(err: unknown) { 8 | return err instanceof Error 9 | && err.message 10 | || 'Something went wrong'; 11 | } 12 | 13 | export function verifyPath(value: string): false | string { 14 | try { 15 | const path = pathApi.resolve(value); 16 | if (!directoryExists(path)) { 17 | return `${path} is not a directory`; 18 | } 19 | } catch (err) { 20 | return getErrorMessage(err); 21 | } 22 | return false; 23 | } 24 | 25 | export function verifySegmentTime(value: SegmentTimeOption): false | string { 26 | if (typeof value === 'number') { 27 | const error = verifySegmentTimeMinimum(value); 28 | if (error) { 29 | return error; 30 | } 31 | } 32 | 33 | if (typeof value === 'string') { 34 | try { 35 | const seconds = transformSegmentTime(value); 36 | const error = verifySegmentTimeMinimum(seconds); 37 | if (error) { 38 | return error; 39 | } 40 | } catch (err) { 41 | return getErrorMessage(err); 42 | } 43 | } 44 | 45 | return false; 46 | } 47 | 48 | export function verifyDirSizeThreshold(value: DirSizeThresholdOption): false | string { 49 | if (typeof value === 'number') { 50 | const error = verifyDirSizeThresholdMinimum(value); 51 | if (error) { 52 | return error; 53 | } 54 | } 55 | 56 | if (typeof value === 'string') { 57 | try { 58 | const bytes = transformDirSizeThreshold(value); 59 | const error = verifyDirSizeThresholdMinimum(bytes); 60 | if (error) { 61 | return error; 62 | } 63 | } catch (err) { 64 | return getErrorMessage(err); 65 | } 66 | } 67 | 68 | return false; 69 | } 70 | 71 | export function verifyDirSizeThresholdMinimum(value: number): false | string { 72 | return value < 200 * Math.pow(1024, 2) 73 | && 'There is no sense to set dirSizeThreshold value to less that 200 MB.'; 74 | } 75 | 76 | export function verifySegmentTimeMinimum(value: number): false | string { 77 | return value < 15 78 | && 'There is no sense to set duration value to less than 15 seconds.'; 79 | } 80 | 81 | export function verifyAllOptions(path: string, { segmentTime, dirSizeThreshold }: Options): string[] { 82 | const errors: string[] = []; 83 | 84 | const pathError = verifyPath(path); 85 | if (pathError) errors.push(pathError); 86 | 87 | if (segmentTime) { 88 | const error = verifySegmentTime(segmentTime); 89 | if (error) errors.push(error); 90 | } 91 | 92 | if (dirSizeThreshold) { 93 | const error = verifyDirSizeThreshold(dirSizeThreshold); 94 | if (error) errors.push(error); 95 | } 96 | 97 | return errors; 98 | } 99 | -------------------------------------------------------------------------------- /test/events/error.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from 'child_process'; 2 | import { verifyAllOptions } from '../../src/validators'; 3 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 4 | import Recorder, { RecorderEvents, RecorderError } from '../../src/recorder'; 5 | import playlistName from '../../src/helpers/playlistName'; 6 | 7 | jest.mock('../../src/validators'); 8 | jest.mock('../../src/helpers/playlistName'); 9 | 10 | let fakeProcess: ChildProcessWithoutNullStreams; 11 | let eventHandler: () => void; 12 | 13 | beforeEach(() => { 14 | jest.mocked(verifyAllOptions).mockReturnValue([]); 15 | fakeProcess = mockSpawnProcess(); 16 | eventHandler = jest.fn().mockName('onError'); 17 | jest.mocked(playlistName).mockReturnValue('playlist'); 18 | }); 19 | 20 | test('should return RecorderError with message given by ffmpeg', async () => { 21 | new Recorder(URI, DESTINATION) 22 | .on(RecorderEvents.ERROR, eventHandler) 23 | .start(); 24 | 25 | // We have to wait next tick 26 | await Promise.resolve(true); 27 | 28 | fakeProcess.emit('error', 'FFMPEG process failed'); 29 | 30 | expect(eventHandler).toBeCalledTimes(1); 31 | expect(eventHandler).toBeCalledWith(new RecorderError('FFMPEG process failed')); 32 | }); 33 | 34 | test('should return RecorderError - process already spawned', async () => { 35 | new Recorder(URI, DESTINATION) 36 | .on(RecorderEvents.ERROR, eventHandler) 37 | .start() 38 | .start(); 39 | 40 | // We have to wait next tick 41 | await Promise.resolve(true); 42 | 43 | expect(eventHandler).toBeCalledTimes(1); 44 | expect(eventHandler).toBeCalledWith(new RecorderError('Process already spawned.')); 45 | }); 46 | 47 | test('should return RecorderError - no processes spawned', async () => { 48 | new Recorder(URI, DESTINATION) 49 | .on(RecorderEvents.ERROR, eventHandler) 50 | .stop(); 51 | 52 | // We have to wait next tick 53 | await Promise.resolve(true); 54 | 55 | expect(eventHandler).toBeCalledTimes(1); 56 | expect(eventHandler).toBeCalledWith(new RecorderError('No process spawned.')); 57 | }); 58 | -------------------------------------------------------------------------------- /test/events/file_created.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from 'child_process'; 2 | import { verifyAllOptions } from '../../src/validators'; 3 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 4 | import Recorder, { RecorderEvents } from '../../src/recorder'; 5 | import playlistName from '../../src/helpers/playlistName'; 6 | 7 | jest.mock('../../src/validators'); 8 | jest.mock('../../src/helpers/playlistName'); 9 | 10 | let fakeProcess: ChildProcessWithoutNullStreams; 11 | let eventHandler: () => void; 12 | 13 | beforeEach(() => { 14 | jest.mocked(verifyAllOptions).mockReturnValue([]); 15 | fakeProcess = mockSpawnProcess(); 16 | eventHandler = jest.fn().mockName('onFileCreated'); 17 | jest.mocked(playlistName).mockReturnValue('playlist'); 18 | }); 19 | 20 | test('should return filename if ffmpeg says: "Opening \'*.mp4\' for writing"', async () => { 21 | new Recorder(URI, DESTINATION) 22 | .on(RecorderEvents.FILE_CREATED, eventHandler) 23 | .start(); 24 | 25 | // We have to wait next tick 26 | await Promise.resolve(true); 27 | 28 | fakeProcess.stderr.emit('data', Buffer.from('Opening \'segment.mp4\' for writing', 'utf8')); 29 | 30 | expect(eventHandler).toBeCalledTimes(1); 31 | expect(eventHandler).toBeCalledWith('segment.mp4'); 32 | }); 33 | 34 | test('should not handle event if ffmpeg says: "Opening \'*.m3u8.tmp\' for writing"', async () => { 35 | new Recorder(URI, DESTINATION) 36 | .on(RecorderEvents.FILE_CREATED, eventHandler) 37 | .start(); 38 | 39 | // We have to wait next tick 40 | await Promise.resolve(true); 41 | 42 | fakeProcess.stderr.emit('data', Buffer.from('Opening \'playlist.m3u8.tmp\' for writing', 'utf8')); 43 | 44 | expect(eventHandler).toBeCalledTimes(0); 45 | }); 46 | -------------------------------------------------------------------------------- /test/events/progress.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ChildProcessWithoutNullStreams } from 'child_process'; 3 | import { verifyAllOptions } from '../../src/validators'; 4 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 5 | import Recorder, { RecorderEvents } from '../../src/recorder'; 6 | import playlistName from '../../src/helpers/playlistName'; 7 | 8 | jest.mock('../../src/validators'); 9 | jest.mock('../../src/helpers/playlistName'); 10 | 11 | let fakeProcess: ChildProcessWithoutNullStreams; 12 | let eventHandler: () => void; 13 | 14 | beforeEach(() => { 15 | jest.mocked(verifyAllOptions).mockReturnValue([]); 16 | fakeProcess = mockSpawnProcess(); 17 | eventHandler = jest.fn().mockName('onProgress'); 18 | jest.mocked(playlistName).mockReturnValue('playlist'); 19 | }); 20 | 21 | test('should return any ffmpeg progress message', async () => { 22 | new Recorder(URI, DESTINATION) 23 | .on(RecorderEvents.PROGRESS, eventHandler) 24 | .start(); 25 | 26 | // We have to wait next tick 27 | await Promise.resolve(true); 28 | 29 | fakeProcess.stderr.emit('data', Buffer.from('Random progress message', 'utf8')); 30 | 31 | expect(eventHandler).toBeCalledTimes(1); 32 | expect(eventHandler).toBeCalledWith('Random progress message'); 33 | }); 34 | -------------------------------------------------------------------------------- /test/events/space_full.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from 'child_process'; 2 | import { verifyAllOptions } from '../../src/validators'; 3 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 4 | import dirSize from '../../src/helpers/space'; 5 | import Recorder, { RecorderEvents, RecorderError } from '../../src/recorder'; 6 | import playlistName from '../../src/helpers/playlistName'; 7 | 8 | jest.mock('../../src/validators'); 9 | jest.mock('../../src/helpers/space'); 10 | jest.mock('../../src/helpers/playlistName'); 11 | 12 | let fakeProcess: ChildProcessWithoutNullStreams; 13 | let onSpaceFull: () => void; 14 | 15 | beforeEach(() => { 16 | jest.mocked(verifyAllOptions).mockReturnValue([]); 17 | fakeProcess = mockSpawnProcess(); 18 | onSpaceFull = jest.fn().mockName('onSpaceFull'); 19 | jest.mocked(playlistName).mockReturnValue('playlist'); 20 | }); 21 | 22 | test('should not evaluate space if "threshold" is undefined', async () => { 23 | jest.mocked(dirSize).mockReturnValue(Infinity); 24 | 25 | new Recorder(URI, DESTINATION) 26 | .on(RecorderEvents.SPACE_FULL, onSpaceFull) 27 | .start(); 28 | 29 | // We have to wait next tick 30 | await Promise.resolve(true); 31 | 32 | fakeProcess.stderr.emit('data', Buffer.from('Opening \'segment.mp4\' for writing', 'utf8')); 33 | 34 | expect(dirSize).toBeCalledTimes(0); 35 | expect(onSpaceFull).toBeCalledTimes(0); 36 | }); 37 | 38 | test('should evaluate space but not rise an event if "used" is less than the "threshold"', async () => { 39 | jest.mocked(dirSize).mockReturnValue(300); 40 | const onStopped = jest.fn().mockName('onStopped'); 41 | 42 | new Recorder(URI, DESTINATION, { dirSizeThreshold: 500 }) 43 | .on(RecorderEvents.SPACE_FULL, onSpaceFull) 44 | .on(RecorderEvents.STOPPED, onStopped) 45 | .start(); 46 | 47 | // We have to wait next tick 48 | await Promise.resolve(true); 49 | 50 | fakeProcess.stderr.emit('data', Buffer.from('Opening \'segment.mp4\' for writing', 'utf8')); 51 | 52 | // dirSize called twice - 1st on start 2nd on opening segment for writing 53 | expect(dirSize).toBeCalledTimes(2); 54 | expect(onSpaceFull).toBeCalledTimes(0); 55 | expect(onStopped).toBeCalledTimes(0); 56 | }); 57 | 58 | test('should evaluate space on start and rise an event if "used" is close to the "threshold"', async () => { 59 | jest.mocked(dirSize).mockReturnValue(496); 60 | const onStopped = jest.fn().mockName('onStopped'); 61 | 62 | new Recorder(URI, DESTINATION, { dirSizeThreshold: 500 }) 63 | .on(RecorderEvents.SPACE_FULL, onSpaceFull) 64 | .on(RecorderEvents.STOPPED, onStopped) 65 | .start(); 66 | 67 | // We have to wait next tick 68 | await Promise.resolve(true); 69 | 70 | expect(dirSize).toBeCalledTimes(1); 71 | expect(onSpaceFull).toBeCalledTimes(1); 72 | expect(onSpaceFull).toBeCalledWith({ 73 | threshold: 500, 74 | used: 496, 75 | }); 76 | expect(onStopped).toBeCalledTimes(1); 77 | expect(onStopped).toBeCalledWith(0, 'space_full'); 78 | }); 79 | 80 | test('should evaluate space on start and rise an event if "used" is bigger than the "threshold"', async () => { 81 | jest.mocked(dirSize).mockReturnValue(600); 82 | const onStopped = jest.fn().mockName('onStopped'); 83 | 84 | new Recorder(URI, DESTINATION, { dirSizeThreshold: 500 }) 85 | .on(RecorderEvents.SPACE_FULL, onSpaceFull) 86 | .on(RecorderEvents.STOPPED, onStopped) 87 | .start(); 88 | 89 | // We have to wait next tick 90 | await Promise.resolve(true); 91 | 92 | expect(dirSize).toBeCalledTimes(1); 93 | expect(onSpaceFull).toBeCalledTimes(1); 94 | expect(onSpaceFull).toBeCalledWith({ 95 | threshold: 500, 96 | used: 600, 97 | }); 98 | expect(onStopped).toBeCalledTimes(1); 99 | expect(onStopped).toBeCalledWith(0, 'space_full'); 100 | }); 101 | 102 | test('should evaluate space twice and rise an event if "used" became bigger than the "threshold" at progress', async () => { 103 | jest.mocked(dirSize).mockReturnValueOnce(200); 104 | const onStop = jest.fn().mockName('onStop'); 105 | 106 | new Recorder(URI, DESTINATION, { dirSizeThreshold: 500 }) 107 | .on(RecorderEvents.SPACE_FULL, onSpaceFull) 108 | .on(RecorderEvents.STOP, onStop) 109 | .start(); 110 | 111 | // We have to wait next tick 112 | await Promise.resolve(true); 113 | 114 | jest.mocked(dirSize).mockReturnValueOnce(600); 115 | 116 | fakeProcess.stderr.emit('data', Buffer.from('Opening \'segment.mp4\' for writing', 'utf8')); 117 | 118 | expect(dirSize).toBeCalledTimes(2); 119 | expect(onSpaceFull).toBeCalledTimes(1); 120 | expect(onSpaceFull).toBeCalledWith({ 121 | threshold: 500, 122 | used: 600, 123 | }); 124 | expect(onStop).toBeCalledTimes(1); 125 | expect(onStop).toBeCalledWith('space_full'); 126 | }); 127 | 128 | test('should return RecorderError - space evaluation failed', async () => { 129 | jest.mocked(dirSize).mockImplementation(() => { 130 | throw new Error('space evaluation failed'); 131 | }); 132 | const onError = jest.fn().mockName('onError'); 133 | 134 | new Recorder(URI, DESTINATION, { dirSizeThreshold: 500 }) 135 | .on(RecorderEvents.SPACE_FULL, onSpaceFull) 136 | .on(RecorderEvents.ERROR, onError) 137 | .start(); 138 | 139 | // We have to wait next tick 140 | await Promise.resolve(true); 141 | 142 | expect(dirSize).toBeCalledTimes(1); 143 | expect(onSpaceFull).toBeCalledTimes(0); 144 | expect(onError).toBeCalledTimes(1); 145 | expect(onError).toBeCalledWith(new RecorderError('space evaluation failed')); 146 | }); 147 | -------------------------------------------------------------------------------- /test/events/start.spec.ts: -------------------------------------------------------------------------------- 1 | import { verifyAllOptions } from '../../src/validators'; 2 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 3 | import Recorder, { RecorderEvents } from '../../src/recorder'; 4 | import playlistName from '../../src/helpers/playlistName'; 5 | 6 | jest.mock('../../src/validators'); 7 | jest.mock('../../src/helpers/playlistName'); 8 | 9 | let onStart: () => void; 10 | 11 | beforeEach(() => { 12 | jest.mocked(verifyAllOptions).mockReturnValue([]); 13 | mockSpawnProcess(); 14 | onStart = jest.fn().mockName('onStart'); 15 | jest.mocked(playlistName).mockReturnValue('playlist'); 16 | }); 17 | 18 | test('should return "programmatically" if .stop() executed', () => { 19 | new Recorder(URI, DESTINATION) 20 | .on(RecorderEvents.START, onStart) 21 | .start(); 22 | 23 | expect(onStart).toBeCalledTimes(1); 24 | expect(onStart).toBeCalledWith('programmatically'); 25 | }); 26 | -------------------------------------------------------------------------------- /test/events/started.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from 'child_process'; 2 | import { verifyAllOptions } from '../../src/validators'; 3 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 4 | import Recorder, { RecorderEvents } from '../../src/recorder'; 5 | import dirSize from '../../src/helpers/space'; 6 | import playlistName from '../../src/helpers/playlistName'; 7 | 8 | jest.mock('../../src/validators'); 9 | jest.mock('../../src/helpers/space'); 10 | jest.mock('../../src/helpers/playlistName'); 11 | 12 | let fakeProcess: ChildProcessWithoutNullStreams; 13 | let eventHandler: () => void; 14 | 15 | beforeEach(() => { 16 | jest.mocked(verifyAllOptions).mockReturnValue([]); 17 | fakeProcess = mockSpawnProcess(); 18 | eventHandler = jest.fn().mockName('onStarted'); 19 | jest.mocked(dirSize).mockReturnValue(0); 20 | jest.mocked(playlistName).mockReturnValue('playlist'); 21 | }); 22 | 23 | const FFMPEG_MESSAGE = `[libx264 @ 0x148816200] 264 - core 163 r3060 5db6aa6 - H.264/MPEG-4 AVC codec - Copyleft 2003-2021 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=12 lookahead_threads=2 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=15 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00 24 | Output #0, hls, to './playlist.m3u8': 25 | Metadata: 26 | title : Any cam 27 | encoder : Lavf58.76.100 28 | Stream #0:0: Video: h264, yuv420p(progressive), 2592x1944, q=2-31, 15 fps, 90k tbn 29 | Metadata: 30 | encoder : Lavc58.134.100 libx264 31 | Side data: 32 | cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A 33 | `; 34 | 35 | test('should return default options + playlist', async () => { 36 | new Recorder(URI, DESTINATION) 37 | .on(RecorderEvents.STARTED, eventHandler) 38 | .start(); 39 | 40 | // We have to wait next tick 41 | await Promise.resolve(true); 42 | 43 | fakeProcess.stderr.emit('data', Buffer.from(FFMPEG_MESSAGE, 'utf8')); 44 | 45 | expect(eventHandler).toBeCalledTimes(1); 46 | expect(eventHandler).toBeCalledWith({ 47 | uri: URI, 48 | destination: DESTINATION, 49 | filePattern: '%Y.%m.%d/%H.%M.%S', 50 | playlist: 'playlist.m3u8', 51 | segmentTime: 600, 52 | noAudio: false, 53 | ffmpegBinary: 'ffmpeg', 54 | }); 55 | }); 56 | 57 | test('should return custom options + playlist', async () => { 58 | new Recorder(URI, DESTINATION, { 59 | title: 'Test Cam', 60 | filePattern: '%Y:%B %d/%I %M: %S%p', 61 | dirSizeThreshold: '500M', 62 | segmentTime: '1h', 63 | noAudio: true, 64 | ffmpegBinary: '/bin/ffmpeg', 65 | }) 66 | .on(RecorderEvents.STARTED, eventHandler) 67 | .start(); 68 | 69 | // We have to wait next tick 70 | await Promise.resolve(true); 71 | 72 | fakeProcess.stderr.emit('data', Buffer.from(FFMPEG_MESSAGE, 'utf8')); 73 | 74 | expect(eventHandler).toBeCalledTimes(1); 75 | expect(eventHandler).toBeCalledWith({ 76 | uri: URI, 77 | destination: DESTINATION, 78 | title: 'Test Cam', 79 | filePattern: '%Y_%B_%d/%I_%M_%S%p', 80 | playlist: 'playlist.m3u8', 81 | dirSizeThreshold: 524288000, 82 | segmentTime: 3600, 83 | noAudio: true, 84 | ffmpegBinary: '/bin/ffmpeg', 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/events/stop.spec.ts: -------------------------------------------------------------------------------- 1 | import { verifyAllOptions } from '../../src/validators'; 2 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 3 | import Recorder, { RecorderEvents } from '../../src/recorder'; 4 | import playlistName from '../../src/helpers/playlistName'; 5 | 6 | jest.mock('../../src/validators'); 7 | jest.mock('../../src/helpers/playlistName'); 8 | 9 | let onStop: () => void; 10 | let onStopped: () => void; 11 | 12 | beforeEach(() => { 13 | jest.mocked(verifyAllOptions).mockReturnValue([]); 14 | mockSpawnProcess(); 15 | onStop = jest.fn().mockName('onStop'); 16 | onStopped = jest.fn().mockName('onStopped'); 17 | jest.mocked(playlistName).mockReturnValue('playlist'); 18 | }); 19 | 20 | test('should return "programmatically" if .stop() executed', async () => { 21 | new Recorder(URI, DESTINATION) 22 | .on(RecorderEvents.STOP, onStop) 23 | .on(RecorderEvents.STOPPED, onStopped) 24 | .start() 25 | .stop(); 26 | 27 | // We have to wait next tick 28 | await Promise.resolve(true); 29 | 30 | expect(onStop).toBeCalledTimes(1); 31 | expect(onStop).toBeCalledWith('programmatically'); 32 | 33 | expect(onStopped).toBeCalledTimes(1); 34 | expect(onStopped).toBeCalledWith(expect.any(Number), 'ffmpeg_exited'); 35 | }); 36 | -------------------------------------------------------------------------------- /test/events/stopped.spec.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from 'child_process'; 2 | import { verifyAllOptions } from '../../src/validators'; 3 | import playlistName from '../../src/helpers/playlistName'; 4 | import { mockSpawnProcess, URI, DESTINATION } from '../helpers'; 5 | import Recorder, { RecorderEvents } from '../../src/recorder'; 6 | 7 | jest.mock('../../src/validators'); 8 | jest.mock('../../src/helpers/playlistName'); 9 | 10 | let fakeProcess: ChildProcessWithoutNullStreams; 11 | let eventHandler: () => void; 12 | 13 | beforeEach(() => { 14 | jest.mocked(verifyAllOptions).mockReturnValue([]); 15 | fakeProcess = mockSpawnProcess(); 16 | eventHandler = jest.fn().mockName('onStopped'); 17 | jest.mocked(playlistName).mockReturnValue('playlist'); 18 | }); 19 | 20 | test('should return FFMPEG exit code', async () => { 21 | new Recorder(URI, DESTINATION) 22 | .on(RecorderEvents.STOPPED, eventHandler) 23 | .start(); 24 | 25 | // We have to wait next tick 26 | await Promise.resolve(true); 27 | 28 | fakeProcess.emit('close', 255); 29 | 30 | expect(eventHandler).toBeCalledTimes(1); 31 | expect(eventHandler).toBeCalledWith(255, 'ffmpeg_exited'); 32 | }); 33 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { spawn, ChildProcessWithoutNullStreams } from 'child_process'; 3 | import path from 'path'; 4 | 5 | jest.mock('child_process'); 6 | 7 | type OnSpawnType = (...args: unknown[]) => void; 8 | 9 | type MockSpawnProcessOptions = { 10 | onSpawn?: OnSpawnType, 11 | }; 12 | 13 | export const URI = 'rtsp://username:password@host/path'; 14 | export const DESTINATION = path.normalize('/media/Recorder'); 15 | 16 | export function mockSpawnProcess(options: MockSpawnProcessOptions = {}) { 17 | const onSpawn = options.onSpawn || (() => null); 18 | 19 | // @ts-ignore 20 | const proc: ChildProcessWithoutNullStreams = new EventEmitter(); 21 | // @ts-ignore 22 | proc.stderr = new EventEmitter(); 23 | proc.kill = () => { 24 | proc.emit('close', 255); 25 | return true; 26 | }; 27 | 28 | jest.mocked(spawn).mockImplementation((...args) => { 29 | onSpawn(...args); 30 | return proc; 31 | }); 32 | 33 | return proc; 34 | } 35 | -------------------------------------------------------------------------------- /test/process.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockSpawnProcess, URI, DESTINATION } from './helpers'; 2 | import Recorder from '../src/recorder'; 3 | import { verifyAllOptions } from '../src/validators'; 4 | import { Options } from '../src/types'; 5 | import playlistName from '../src/helpers/playlistName'; 6 | 7 | jest.mock('../src/validators'); 8 | jest.mock('../src/helpers/playlistName'); 9 | 10 | beforeEach(() => { 11 | jest.mocked(verifyAllOptions).mockReturnValue([]); 12 | jest.mocked(playlistName).mockReturnValue('playlist'); 13 | }); 14 | 15 | it('Spawn arguments with no additional options defined', () => { 16 | function onSpawn(command: string, args: ReadonlyArray, options: Options) { 17 | expect(command).toEqual('ffmpeg'); 18 | expect(args).toEqual([ 19 | '-rtsp_transport', 'tcp', 20 | '-i', URI, 21 | '-reset_timestamps', '1', 22 | '-c:a', 'aac', 23 | '-strftime', '1', 24 | '-strftime_mkdir', '1', 25 | '-hls_time', '600', 26 | '-hls_list_size', '0', 27 | '-hls_segment_filename', '%Y.%m.%d/%H.%M.%S.mp4', 28 | './playlist.m3u8', 29 | ]); 30 | expect(options).toEqual({ detached: false, cwd: DESTINATION }); 31 | } 32 | 33 | // @ts-ignore 34 | mockSpawnProcess({ onSpawn }); 35 | 36 | new Recorder(URI, DESTINATION).start(); 37 | }); 38 | 39 | it('Spawn arguments with options defined', () => { 40 | function onSpawn(command: string, args: ReadonlyArray, options: Options) { 41 | expect(command).toEqual('ffmpeg'); 42 | expect(args).toEqual([ 43 | '-rtsp_transport', 'tcp', 44 | '-i', URI, 45 | '-reset_timestamps', '1', 46 | '-metadata', 'title="Any video title"', 47 | '-an', 48 | '-strftime', '1', 49 | '-strftime_mkdir', '1', 50 | '-hls_time', '1000', 51 | '-hls_list_size', '0', 52 | '-hls_segment_filename', '%Y.%m.%d/%H.%M.%S.mp4', 53 | './playlist.m3u8', 54 | ]); 55 | expect(options).toEqual({ detached: false, cwd: DESTINATION }); 56 | } 57 | 58 | // @ts-ignore 59 | mockSpawnProcess({ onSpawn }); 60 | 61 | new Recorder(URI, DESTINATION, { title: 'Any video title', segmentTime: 1000, noAudio: true }).start(); 62 | }); 63 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "**.js", 5 | "**.ts", 6 | "**/**.js", 7 | "**/**.ts", 8 | ], 9 | "exclude": [ 10 | "node_modules", 11 | "dist", 12 | "coverage" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "example", 5 | "declaration": false, 6 | "declarationMap": false, 7 | "sourceMap": false, 8 | }, 9 | "include": ["example.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "declaration": true, 7 | "declarationMap": false, 8 | "sourceMap": false, 9 | "removeComments": true, 10 | "lib": ["es2018"], 11 | "target": "es2018", 12 | }, 13 | "include": [ 14 | "src/**/*.ts", 15 | ], 16 | "exclude": [ 17 | "node_modules", 18 | "dist", 19 | "coverage", 20 | "**/*.spec.ts", 21 | ] 22 | } 23 | --------------------------------------------------------------------------------