├── .dockerignore ├── src ├── public │ ├── favicon.ico │ ├── manifest.json │ ├── static │ │ ├── css │ │ │ └── main.a08da4e0.chunk.css │ │ └── js │ │ │ ├── runtime~main.c5541365.js │ │ │ └── main.4071166f.chunk.js │ └── admin │ │ └── index.html ├── api │ ├── routes │ │ ├── clip.js │ │ ├── server.js │ │ ├── trans.js │ │ ├── streams.js │ │ └── relay.js │ └── controllers │ │ ├── server.js │ │ ├── relay.js │ │ ├── streams.js │ │ └── clip.js ├── node_core_ctx.js ├── node_core_bitop.js ├── node_core_logger.js ├── node_fission_session.js ├── node_relay_session.js ├── node_rtmp_server.js ├── node_core_utils.js ├── node_media_server.js ├── node_fission_server.js ├── node_rtmp_handshake.js ├── node_http_server.js ├── node_trans_session.js ├── node_flv_session.js ├── node_trans_server.js ├── node_relay_server.js ├── node_core_av.js └── node_rtmp_client.js ├── .github ├── FUNDING.yml └── workflows │ ├── npm.yml │ └── docker-image.yml ├── test ├── test_genAuth.js ├── test_run.js ├── test_api.http └── test_rtmp_client.js ├── Dockerfile ├── ecosystem.config.js ├── .eslintrc.js ├── bin ├── certrequest.csr ├── privatekey.pem ├── certificate.pem └── app.js ├── .gitignore ├── misc ├── config.sample.js ├── utils │ ├── transcode.js │ └── helpers.js └── index.js ├── LICENSE ├── package.json ├── archive.js └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuacLive/Guac-Media-Server/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: guaclive 4 | custom: ["https://www.paypal.me/datagutt"] 5 | -------------------------------------------------------------------------------- /test/test_genAuth.js: -------------------------------------------------------------------------------- 1 | const md5 = require('crypto').createHash('md5'); 2 | let key = 'nodemedia2017privatekey'; 3 | let exp = (Date.now() / 1000 | 0) + 60; 4 | let streamId = '/live/stream'; 5 | console.log(exp+'-'+md5.update(streamId+'-'+exp+'-'+key).digest('hex')); 6 | -------------------------------------------------------------------------------- /src/api/routes/clip.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const clipController = require('../controllers/clip'); 3 | 4 | module.exports = (context) => { 5 | let router = express.Router(); 6 | router.post('/', clipController.clip.bind(context)); 7 | return router; 8 | }; -------------------------------------------------------------------------------- /src/api/routes/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const serverController = require('../controllers/server'); 3 | 4 | module.exports = (context) => { 5 | let router = express.Router(); 6 | router.get('/', serverController.getInfo.bind(context)); 7 | return router; 8 | }; 9 | -------------------------------------------------------------------------------- /test/test_run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const NodeMediaServer = require('..'); 4 | const config = { 5 | rtmp: { 6 | port: 1935, 7 | }, 8 | http: { 9 | port: 8000, 10 | }, 11 | }; 12 | 13 | let nms = new NodeMediaServer(config); 14 | 15 | setTimeout(() => { 16 | nms.stop(); 17 | }, 3000); 18 | 19 | nms.run(); -------------------------------------------------------------------------------- /src/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "NMS", 3 | "name": "NodeMediaServer", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage builds require Docker 17.05 or higher on the daemon and client. 2 | 3 | FROM jrottenberg/ffmpeg:4.0-scratch as ffmpeg 4 | FROM node:12.14.1-alpine 5 | 6 | COPY --from=ffmpeg / / 7 | 8 | WORKDIR /usr/src/app 9 | 10 | COPY package*.json ./ 11 | 12 | RUN npm i 13 | 14 | COPY . . 15 | 16 | EXPOSE 1935 8000 8443 17 | 18 | CMD ["node", "misc/index.js"] 19 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps : [{ 3 | name: 'Guac-Media-Server', 4 | script: 'node misc/index.js', 5 | instances: 1, 6 | autorestart: true, 7 | watch: false, 8 | //max_memory_restart: '2G', 9 | env: { 10 | NODE_ENV: 'development' 11 | }, 12 | env_production: { 13 | NODE_ENV: 'production' 14 | } 15 | }] 16 | }; 17 | -------------------------------------------------------------------------------- /src/api/routes/trans.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const transController = require('../controllers/trans'); 3 | 4 | module.exports = (context) => { 5 | let router = express.Router(); 6 | router.get('/', transController.getTrans.bind(context)); 7 | router.post('/', transController.addTrans.bind(context)); 8 | router.delete('/', transController.deleteTrans.bind(context)); 9 | return router; 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: Test and push NMP 2 | 3 | on: 4 | push: 5 | tags: 6 | - v** 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: 8 16 | - run: npm install 17 | - run: npm test 18 | - uses: JS-DevTools/npm-publish@v1 19 | with: 20 | token: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /src/api/routes/streams.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const streamController = require('../controllers/streams'); 3 | 4 | module.exports = (context) => { 5 | let router = express.Router(); 6 | router.post('/trans', streamController.postStreamTrans.bind(context)); 7 | router.get('/', streamController.getStreams.bind(context)); 8 | router.get('/:app/:stream', streamController.getStream.bind(context)); 9 | router.delete('/:app/:stream', streamController.delStream.bind(context)); 10 | return router; 11 | }; 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'commonjs': true, 4 | 'es2020': true, 5 | 'node': true 6 | }, 7 | 'parserOptions': { 8 | 'ecmaVersion': 11 9 | }, 10 | 'rules': { 11 | 'indent': [ 12 | 'error', 13 | 2, 14 | {SwitchCase: 1} 15 | ], 16 | 'linebreak-style': [ 17 | 'error', 18 | 'unix' 19 | ], 20 | 'quotes': [ 21 | 'error', 22 | 'single' 23 | ], 24 | 'semi': [ 25 | 'error', 26 | 'always' 27 | ] 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/node_core_ctx.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 18/3/2. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const EventEmitter = require('events'); 7 | 8 | let transSessions = new Map(); 9 | let sessions = new Map(); 10 | let publishers = new Map(); 11 | let idlePlayers = new Set(); 12 | let nodeEvent = new EventEmitter(); 13 | let stat = { 14 | inbytes: 0, 15 | outbytes: 0, 16 | accepted: 0 17 | }; 18 | module.exports = { transSessions, sessions, publishers, idlePlayers, nodeEvent, stat }; -------------------------------------------------------------------------------- /src/public/static/css/main.a08da4e0.chunk.css: -------------------------------------------------------------------------------- 1 | body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.trigger{font-size:18px;line-height:64px;padding:0 24px;cursor:pointer;-webkit-transition:color .3s;transition:color .3s}.trigger:hover{color:#1890ff}.logo{background:#002140}.logo h1{color:#fff;line-height:64px;font-size:20px;text-align:center} 2 | /*# sourceMappingURL=main.a08da4e0.chunk.css.map */ -------------------------------------------------------------------------------- /src/api/routes/relay.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const relayController = require('../controllers/relay'); 3 | 4 | module.exports = (context) => { 5 | let router = express.Router(); 6 | router.get('/', relayController.getStreams.bind(context)); 7 | router.get('/:id', relayController.getStreamByID.bind(context)); 8 | router.get('/:app/:name', relayController.getStreamByName.bind(context)); 9 | router.post('/task', relayController.relayStream.bind(context)); 10 | router.post('/pull', relayController.pullStream.bind(context)); 11 | router.post('/push', relayController.pushStream.bind(context)); 12 | router.delete('/:id', relayController.delStream.bind(context)); 13 | return router; 14 | }; -------------------------------------------------------------------------------- /bin/certrequest.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIB2TCCAUICAQAwezELMAkGA1UEBhMCQ04xEDAOBgNVBAgTB1NpY2h1YW4xEDAO 3 | BgNVBAcTB0NoZW5nZHUxEjAQBgNVBAoTCU5vZGVNZWRpYTERMA8GA1UEAxMIaWxs 4 | dXNwYXMxITAfBgkqhkiG9w0BCQEWEmlsbHVzcGFzQGdtYWlsLmNvbTCBnzANBgkq 5 | hkiG9w0BAQEFAAOBjQAwgYkCgYEAxBzdiRMPaaHu80diod0WyehipiVB1OWwd8Ez 6 | LjZznB1d/HnNZ8GdfW3U7PpRUWdX/XIfvj4+FhmXVmstgS62bBesqFM8A0btTanc 7 | /cy+kbJAq3pCS2Tiwsqid2efuZjN+SavlHW4G+6+ZXE92dWffO8gaa4v5isfUBb2 8 | RZknqvsCAwEAAaAeMBwGCSqGSIb3DQEJBzEPEw1ub2RlbWVkaWEyMDE3MA0GCSqG 9 | SIb3DQEBBQUAA4GBACLGauSix2qtjRnCABZiyH8kIeLWPpIreXBGLKQGU6Mof2Hn 10 | 0yhisL6toafLRHqXTlyKD5V3KldQ+P7Lb85ejtqB/URJfpBohs+x3myb/f8O1Qnr 11 | isLQGuqNarB78APtI85uGBl7+bvfMCXBvrha7C9WqJHluHkHkkctkzXFtUim 12 | -----END CERTIFICATE REQUEST----- 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | *.m4s 30 | *.m3u8 31 | *.ts 32 | *.mp4 33 | *.mpd 34 | 35 | # Guac-additions 36 | .vscode 37 | media 38 | misc/config.js 39 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and push Docker images 2 | 3 | on: 4 | push: 5 | tags: 6 | - v** 7 | 8 | jobs: 9 | 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Get tag 18 | id: tag 19 | uses: dawidd6/action-get-tag@v1 20 | with: 21 | strip_v: true 22 | 23 | - name: Login to Docker Hub 24 | uses: docker/login-action@v1 25 | with: 26 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 27 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 28 | 29 | - name: Build and push 30 | uses: docker/build-push-action@v3 31 | with: 32 | push: true 33 | tags: guaclive/guac-media-server:latest, guaclive/guac-media-server:${{steps.tag.outputs.tag}} 34 | -------------------------------------------------------------------------------- /bin/privatekey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXQIBAAKBgQDEHN2JEw9poe7zR2Kh3RbJ6GKmJUHU5bB3wTMuNnOcHV38ec1n 3 | wZ19bdTs+lFRZ1f9ch++Pj4WGZdWay2BLrZsF6yoUzwDRu1Nqdz9zL6RskCrekJL 4 | ZOLCyqJ3Z5+5mM35Jq+Udbgb7r5lcT3Z1Z987yBpri/mKx9QFvZFmSeq+wIDAQAB 5 | AoGAbuzYzaivRhNnAcn12xIfyrKb4dgfBVmp2AK6fUAlYj8mIyGN8ksMVp7iGex4 6 | RHAMz/lWRRgVrBBrjmDvCyut2DYG/20wH/npfQnbAhGXyxkeeoZtzPitARVnY2s4 7 | J90e9+uhSrantfpDVGU35ATNB9mq/w12YkIJgtY0aTA3NYkCQQDvBI2FY14wEEjR 8 | 78YhKu/ucNz1WH1VHZLVsugY7nrGPSNjjBRfPBtK5lmbF+1loSbNFFr/x1pY0JVg 9 | VZu/VHZ9AkEA0gvr5aB6F5CHKHbspxTxVj5R0woBSY2jBbwjfjV3xlRUtjyZ9xKh 10 | 9x1mKCYDAGtPLH12nvTg++Bn/qtw64lI1wJBALWVcuK8jCjdpkT/8TjvgtpWGje2 11 | o3kPf6ckRRnzy4hhmEofeLalVmK/v6GJOwyzsmOpLD0XubaxuFo4j5t60o0CQFFb 12 | HLuMNL371N3vciolCnUFHlxHe8gpfAM0o+q2evXupAER5/Cy3tkAIhla377B0aDB 13 | 17gp0Rq+CImzjcEtI3ECQQCpSrdlQQm/fyjbpgyT0ePeNmQOywIEbvn+zcLexV1L 14 | W2bt/NpvOZMPdAKuNst2z2xERldQJiqR/hkCVCmnxG2o 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /bin/certificate.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICbTCCAdYCCQCDVL7/dIP3TDANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJD 3 | TjEQMA4GA1UECBMHU2ljaHVhbjEQMA4GA1UEBxMHQ2hlbmdkdTESMBAGA1UEChMJ 4 | Tm9kZU1lZGlhMREwDwYDVQQDEwhpbGx1c3BhczEhMB8GCSqGSIb3DQEJARYSaWxs 5 | dXNwYXNAZ21haWwuY29tMB4XDTE3MTEyMDEyMTAyOFoXDTE3MTIyMDEyMTAyOFow 6 | ezELMAkGA1UEBhMCQ04xEDAOBgNVBAgTB1NpY2h1YW4xEDAOBgNVBAcTB0NoZW5n 7 | ZHUxEjAQBgNVBAoTCU5vZGVNZWRpYTERMA8GA1UEAxMIaWxsdXNwYXMxITAfBgkq 8 | hkiG9w0BCQEWEmlsbHVzcGFzQGdtYWlsLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOB 9 | jQAwgYkCgYEAxBzdiRMPaaHu80diod0WyehipiVB1OWwd8EzLjZznB1d/HnNZ8Gd 10 | fW3U7PpRUWdX/XIfvj4+FhmXVmstgS62bBesqFM8A0btTanc/cy+kbJAq3pCS2Ti 11 | wsqid2efuZjN+SavlHW4G+6+ZXE92dWffO8gaa4v5isfUBb2RZknqvsCAwEAATAN 12 | BgkqhkiG9w0BAQUFAAOBgQCL3vbOutCrsnRLb917Ore4EV8Cxkl+7B2oVWM1HCTs 13 | 12qG+Kjf7Mv1tS1H9VI6lvIiZ+YihYWHXsMe9/8KlqUEHJuEgdi52s+uImMa5L7j 14 | sgeQoyP3gZm/Pcg8bFmnx2zHg5Iw/cuPbHqoDpJbJcyElLEYV1SLuCbhVL7pIdJb 15 | Sg== 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /misc/config.sample.js: -------------------------------------------------------------------------------- 1 | /* Check misc/index.js for all available options */ 2 | module.exports = { 3 | /* This is the endpoint where the live/publish POST request will be sent */ 4 | endpoint: 'http://api.example.com', 5 | api_user: 'nms', 6 | api_pass: 'nms', 7 | api_secret: 'nms', 8 | http_port: 8000, 9 | /* Whether to enable stream quality options */ 10 | transcode: true, 11 | /* Whether to archive all streams */ 12 | archive: false, 13 | /* Whether to generate thumbnails for all streams */ 14 | generateThumbnail: false, 15 | /* 16 | https_port: 8443, 17 | https_cert: '/path/to/cert.pem', 18 | https_key: '/path/to/key.pem', 19 | */ 20 | /* For uploading a stream to archives */ 21 | s3: { 22 | accessKey: '', 23 | secret: '', 24 | bucket: '', 25 | endpoint: '', 26 | concurrency: 4, 27 | publishUrl: '' 28 | }, 29 | ffmpeg_path: '/usr/bin/ffmpeg' 30 | }; 31 | -------------------------------------------------------------------------------- /test/test_api.http: -------------------------------------------------------------------------------- 1 | ### get all relay tasks 2 | GET http://127.0.0.1:8000/api/relay HTTP/1.1 3 | Authorization: Basic admin:admin 4 | 5 | ### create relay pull task 6 | POST http://127.0.0.1:8000/api/relay/pull HTTP/1.1 7 | Authorization: Basic admin:admin 8 | Content-Type: application/x-www-form-urlencoded 9 | 10 | app=live 11 | &name=bbb 12 | &url=rtmp://192.168.0.2/live/bbb 13 | 14 | ### create relay push task 15 | POST http://127.0.0.1:8000/api/relay/push HTTP/1.1 16 | Authorization: Basic admin:admin 17 | Content-Type: application/x-www-form-urlencoded 18 | 19 | app=live 20 | &name=bbb 21 | &url=rtmp://192.168.0.2/live/bbb2 22 | 23 | 24 | ### create relay task 25 | POST http://127.0.0.1:8000/api/relay/task HTTP/1.1 26 | Authorization: Basic admin:admin 27 | Content-Type: application/x-www-form-urlencoded 28 | 29 | path=rtmp://192.168.0.2/live/bbb 30 | &url=rtmp://127.0.0.1/live/bbb 31 | 32 | ### delete relay task 33 | DELETE http://127.0.0.1:8000/api/relay/N3MKMPHU HTTP/1.1 34 | Authorization: Basic admin:admin 35 | -------------------------------------------------------------------------------- /test/test_rtmp_client.js: -------------------------------------------------------------------------------- 1 | const NodeRtmpClient = require('./node_rtmp_client'); 2 | 3 | let rc = new NodeRtmpClient('rtmp://192.168.0.10/live/stream'); 4 | let rp = new NodeRtmpClient('rtmp://192.168.0.20/live/stream'); 5 | rc.on('audio', (audioData, timestamp) => { 6 | rp.pushAudio(audioData, timestamp); 7 | }); 8 | 9 | rc.on('video', (videoData, timestamp) => { 10 | rp.pushVideo(videoData, timestamp); 11 | }); 12 | 13 | rc.on('script', (scriptData, timestamp) => { 14 | rp.pushScript(scriptData, timestamp); 15 | }); 16 | 17 | rc.on('status', (info) => { 18 | console.log('player on status', info); 19 | if(info.code === 'NetStream.Play.UnpublishNotify') { 20 | rc.stop(); 21 | } 22 | }); 23 | 24 | rc.on('close', () => { 25 | console.log('player on close'); 26 | rp.stop(); 27 | }); 28 | 29 | rp.on('close', () => { 30 | console.log('publisher on close'); 31 | rc.stop(); 32 | }); 33 | 34 | rp.on('status', (info) => { 35 | console.log('publisher on status', info); 36 | if (info.code === 'NetStream.Publish.Start') { 37 | rc.startPull(); 38 | } 39 | }); 40 | 41 | rp.startPush(); 42 | 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-2020 Chen Mingliang 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guac-media-server", 3 | "version": "2.4.7", 4 | "description": "A Node.js implementation of RTMP Server", 5 | "main": "misc/index.js", 6 | "scripts": { 7 | "start": "node misc/index.js", 8 | "test": "node test/test_run.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/GuacLive/Guac-Media-Server.git" 13 | }, 14 | "keywords": [ 15 | "rtmp", 16 | "flv", 17 | "server" 18 | ], 19 | "author": "Chen Mingliang", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/GuacLive/Guac-Media-Server/issues" 23 | }, 24 | "homepage": "https://github.com/GuacLive/Guac-Media-Server#readme", 25 | "dependencies": { 26 | "aws-sdk": "^2.1167.0", 27 | "axios": "^0.27.2", 28 | "basic-auth-connect": "^1.0.0", 29 | "bluebird": "^3.7.2", 30 | "chalk": "^4", 31 | "dateformat": "^4", 32 | "express": "^5.0.0-alpha.8", 33 | "hls-parser": "^0.10.3", 34 | "lodash": ">=4.17.13", 35 | "minimist": "^1.2.6", 36 | "mkdirp": "1.0.4", 37 | "node-cron": "^3.0.1", 38 | "ws": "^8.8.0" 39 | }, 40 | "engines": { 41 | "node": ">=8.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/node_core_bitop.js: -------------------------------------------------------------------------------- 1 | 2 | class Bitop { 3 | constructor(buffer) { 4 | this.buffer = buffer; 5 | this.buflen = buffer.length; 6 | this.bufpos = 0; 7 | this.bufoff = 0; 8 | this.iserro = false; 9 | } 10 | 11 | read(n) { 12 | let v = 0; 13 | let d = 0; 14 | while (n) { 15 | if (n < 0 || this.bufpos >= this.buflen) { 16 | this.iserro = true; 17 | return 0; 18 | } 19 | 20 | this.iserro = false; 21 | d = this.bufoff + n > 8 ? 8 - this.bufoff : n; 22 | 23 | v <<= d; 24 | v += (this.buffer[this.bufpos] >> (8 - this.bufoff - d)) & (0xff >> (8 - d)); 25 | 26 | this.bufoff += d; 27 | n -= d; 28 | 29 | if (this.bufoff == 8) { 30 | this.bufpos++; 31 | this.bufoff = 0; 32 | } 33 | } 34 | return v; 35 | } 36 | 37 | look(n) { 38 | let p = this.bufpos; 39 | let o = this.bufoff; 40 | let v = this.read(n); 41 | this.bufpos = p; 42 | this.bufoff = o; 43 | return v; 44 | } 45 | 46 | read_golomb() { 47 | let n; 48 | for (n = 0; this.read(1) == 0 && !this.iserro; n++); 49 | return (1 << n) + this.read(n) - 1; 50 | } 51 | } 52 | 53 | module.exports = Bitop; 54 | -------------------------------------------------------------------------------- /src/node_core_logger.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | const LOG_TYPES = { 4 | NONE: 0, 5 | ERROR: 1, 6 | NORMAL: 2, 7 | DEBUG: 3, 8 | FFDEBUG: 4 9 | }; 10 | 11 | let logType = LOG_TYPES.NORMAL; 12 | 13 | const setLogType = (type) => { 14 | if (typeof type !== 'number') return; 15 | 16 | logType = type; 17 | }; 18 | 19 | const logTime = () => { 20 | let nowDate = new Date(); 21 | return nowDate.toLocaleDateString() + ' ' + nowDate.toLocaleTimeString([], { hour12: false }); 22 | }; 23 | 24 | const log = (...args) => { 25 | if (logType < LOG_TYPES.NORMAL) return; 26 | 27 | console.log(logTime(), process.pid, chalk.bold.green('[INFO]'), ...args); 28 | }; 29 | 30 | const error = (...args) => { 31 | if (logType < LOG_TYPES.ERROR) return; 32 | 33 | console.log(logTime(), process.pid, chalk.bold.red('[ERROR]'), ...args); 34 | }; 35 | 36 | const debug = (...args) => { 37 | if (logType < LOG_TYPES.DEBUG) return; 38 | 39 | console.log(logTime(), process.pid, chalk.bold.blue('[DEBUG]'), ...args); 40 | }; 41 | 42 | const ffdebug = (...args) => { 43 | if (logType < LOG_TYPES.FFDEBUG) return; 44 | 45 | console.log(logTime(), process.pid, chalk.bold.blue('[FFDEBUG]'), ...args); 46 | }; 47 | 48 | module.exports = { 49 | LOG_TYPES, 50 | setLogType, 51 | 52 | log, error, debug, ffdebug 53 | }; -------------------------------------------------------------------------------- /src/public/static/js/runtime~main.c5541365.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c { return n; }); 30 | this.ffmpeg_exec = spawn(this.conf.ffmpeg, argv); 31 | this.ffmpeg_exec.on('error', (e) => { 32 | Logger.ffdebug(e); 33 | }); 34 | 35 | this.ffmpeg_exec.stdout.on('data', (data) => { 36 | Logger.ffdebug(`FF输出:${data}`); 37 | }); 38 | 39 | this.ffmpeg_exec.stderr.on('data', (data) => { 40 | Logger.ffdebug(`FF输出:${data}`); 41 | }); 42 | 43 | this.ffmpeg_exec.on('close', (code) => { 44 | Logger.log('[Fission end] ' + this.conf.streamPath); 45 | this.emit('end'); 46 | }); 47 | } 48 | 49 | end() { 50 | this.ffmpeg_exec.kill(); 51 | } 52 | } 53 | 54 | module.exports = NodeFissionSession; -------------------------------------------------------------------------------- /src/public/admin/index.html: -------------------------------------------------------------------------------- 1 | Node-Media-Server Admin
-------------------------------------------------------------------------------- /bin/app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const NodeMediaServer = require('..'); 4 | let argv = require('minimist')(process.argv.slice(2), 5 | { 6 | string:['rtmp_port','http_port','https_port'], 7 | alias: { 8 | 'rtmp_port': 'r', 9 | 'http_port': 'h', 10 | 'https_port': 's', 11 | }, 12 | default:{ 13 | 'rtmp_port': 1935, 14 | 'http_port': 8000, 15 | 'https_port': 8443, 16 | } 17 | }); 18 | 19 | if (argv.help) { 20 | console.log('Usage:'); 21 | console.log(' node-media-server --help // print help information'); 22 | console.log(' node-media-server --rtmp_port 1935 or -r 1935'); 23 | console.log(' node-media-server --http_port 8000 or -h 8000'); 24 | console.log(' node-media-server --https_port 8443 or -s 8443'); 25 | process.exit(0); 26 | } 27 | 28 | const config = { 29 | rtmp: { 30 | port: argv.rtmp_port, 31 | chunk_size: 60000, 32 | gop_cache: true, 33 | ping: 30, 34 | ping_timeout: 60, 35 | let nms = new NodeMediaServer(config); 36 | nms.run(); 37 | 38 | nms.on('preConnect', (id, args) => { 39 | console.log('[NodeEvent on preConnect]', `id=${id} args=${JSON.stringify(args)}`); 40 | // let session = nms.getSession(id); 41 | // session.reject(); 42 | }); 43 | 44 | nms.on('postConnect', (id, args) => { 45 | }); 46 | 47 | nms.on('doneConnect', (id, args) => { 48 | console.log('[NodeEvent on doneConnect]', `id=${id} args=${JSON.stringify(args)}`); 49 | }); 50 | 51 | nms.on('prePublish', (id, StreamPath, args) => { 52 | console.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 53 | // let session = nms.getSession(id); 54 | // session.reject(); 55 | }); 56 | 57 | nms.on('postPublish', (id, StreamPath, args) => { 58 | console.log('[NodeEvent on postPublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 59 | }); 60 | 61 | nms.on('donePublish', (id, StreamPath, args) => { 62 | console.log('[NodeEvent on donePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 63 | }); 64 | 65 | nms.on('prePlay', (id, StreamPath, args) => { 66 | console.log('[NodeEvent on prePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 67 | // let session = nms.getSession(id); 68 | // session.reject(); 69 | }); 70 | 71 | nms.on('postPlay', (id, StreamPath, args) => { 72 | console.log('[NodeEvent on postPlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 73 | }); 74 | 75 | nms.on('donePlay', (id, StreamPath, args) => { 76 | console.log('[NodeEvent on donePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 77 | }); 78 | -------------------------------------------------------------------------------- /src/node_relay_session.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 18/3/16. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const Logger = require('./node_core_logger'); 7 | const NodeCoreUtils = require('./node_core_utils'); 8 | 9 | const EventEmitter = require('events'); 10 | const { spawn } = require('child_process'); 11 | 12 | const RTSP_TRANSPORT = ['udp', 'tcp', 'udp_multicast', 'http']; 13 | 14 | class NodeRelaySession extends EventEmitter { 15 | constructor(conf) { 16 | super(); 17 | this.conf = conf; 18 | this.id = NodeCoreUtils.generateNewSessionID(); 19 | this.ts = Date.now() / 1000 | 0; 20 | this.TAG = 'relay'; 21 | } 22 | 23 | run() { 24 | let format = this.conf.ouPath.startsWith('rtsp://') ? 'rtsp' : 'flv'; 25 | let argv = ['-re', '-fflags', 'nobuffer', '-i', this.conf.inPath, '-c', 'copy', '-f', format, this.conf.ouPath, 26 | '-stimeout', '10000000 ','-reconnect', '1', '-reconnect_at_eof', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '2'] 27 | if (this.conf.inPath[0] === '/' || this.conf.inPath[1] === ':') { 28 | argv.unshift('-1'); 29 | argv.unshift('-stream_loop'); 30 | } 31 | 32 | if (this.conf.inPath.startsWith('rtsp://') && this.conf.rtsp_transport) { 33 | if (RTSP_TRANSPORT.indexOf(this.conf.rtsp_transport) > -1) { 34 | argv.unshift(this.conf.rtsp_transport); 35 | argv.unshift('-rtsp_transport'); 36 | } 37 | } 38 | 39 | Logger.log('[relay task] id=' + this.id, 'cmd=ffmpeg', argv.join(' ')); 40 | 41 | this.ffmpeg_exec = spawn(this.conf.ffmpeg, argv); 42 | this.ffmpeg_exec.on('error', (e) => { 43 | Logger.ffdebug(e); 44 | }); 45 | 46 | this.ffmpeg_exec.stdout.on('data', (data) => { 47 | Logger.ffdebug(`FF输出:${data}`); 48 | }); 49 | 50 | this.ffmpeg_exec.stderr.on('data', (data) => { 51 | Logger.ffdebug(`FF输出:${data}`); 52 | }); 53 | 54 | this.ffmpeg_exec.on('close', (code) => { 55 | Logger.log('[Relay end] id=', this.id, 'code=' + code); 56 | this.ffmpeg_exec = null; 57 | if (!this._ended) { 58 | this._runtimeout = setTimeout(() => { 59 | this._runtimeout = null; 60 | this.run(); 61 | }, 1000); 62 | } else { 63 | this.emit('end', this.id); 64 | } 65 | }); 66 | } 67 | 68 | end() { 69 | this._ended = true; 70 | if (this._runtimeout != null) { 71 | clearTimeout(this._runtimeout); 72 | this._runtimeout = null; 73 | } 74 | if (this.ffmpeg_exec) { 75 | this.ffmpeg_exec.kill(); 76 | } else { 77 | this.emit('end', this.id); 78 | } 79 | } 80 | } 81 | 82 | module.exports = NodeRelaySession; 83 | -------------------------------------------------------------------------------- /src/node_rtmp_server.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 17/8/1. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const Logger = require('./node_core_logger'); 7 | 8 | const Tls = require('tls'); 9 | const Fs = require('fs'); 10 | const Net = require('net'); 11 | const NodeRtmpSession = require('./node_rtmp_session'); 12 | 13 | const context = require('./node_core_ctx'); 14 | 15 | const RTMP_PORT = 1935; 16 | const RTMP_HOST = '0.0.0.0'; 17 | const RTMPS_PORT = 443; 18 | const RTMPS_HOST = '0.0.0.0'; 19 | 20 | class NodeRtmpServer { 21 | constructor(config) { 22 | config.rtmp.port = this.port = config.rtmp.port ? config.rtmp.port : RTMP_PORT; 23 | config.rtmp.host = this.host = config.rtmp.host ? config.rtmp.host : RTMP_HOST; 24 | this.tcpServer = Net.createServer((socket) => { 25 | let session = new NodeRtmpSession(config, socket); 26 | session.run(); 27 | }); 28 | 29 | if (config.rtmp.ssl){ 30 | config.rtmp.ssl.port = this.sslPort = config.rtmp.ssl.port ? config.rtmp.ssl.port : RTMPS_PORT; 31 | config.rtmp.ssl.host = this.sslHost = config.rtmp.ssl.host ? config.rtmp.ssl.host : RTMPS_HOST; 32 | try { 33 | const options = { 34 | key: Fs.readFileSync(config.rtmp.ssl.key), 35 | cert: Fs.readFileSync(config.rtmp.ssl.cert) 36 | }; 37 | this.tlsServer = Tls.createServer(options, (socket) => { 38 | let session = new NodeRtmpSession(config, socket); 39 | session.run(); 40 | }); 41 | } catch (e) { 42 | Logger.error(`Node Media Rtmps Server error while reading ssl certs: <${e}>`); 43 | } 44 | } 45 | } 46 | 47 | run() { 48 | this.tcpServer.listen(this.port, this.host, () => { 49 | Logger.log(`Node Media Rtmp Server started on: ${this.host}:${this.port}`); 50 | }); 51 | 52 | this.tcpServer.on('error', (e) => { 53 | Logger.error(`Node Media Rtmp Server ${e}`); 54 | }); 55 | 56 | this.tcpServer.on('close', () => { 57 | Logger.log('Node Media Rtmp Server Close.'); 58 | }); 59 | 60 | if (this.tlsServer) { 61 | this.tlsServer.listen(this.sslPort, this.sslHost, () => { 62 | Logger.log(`Node Media Rtmps Server started on: ${this.sslHost}:${this.sslPort}`); 63 | }); 64 | 65 | this.tlsServer.on('error', (e) => { 66 | Logger.error(`Node Media Rtmps Server ${e}`); 67 | }); 68 | 69 | this.tlsServer.on('close', () => { 70 | Logger.log('Node Media Rtmps Server Close.'); 71 | }); 72 | } 73 | } 74 | 75 | stop() { 76 | this.tcpServer.close(); 77 | 78 | if (this.tlsServer) { 79 | this.tlsServer.close(); 80 | } 81 | 82 | context.sessions.forEach((session, id) => { 83 | if (session instanceof NodeRtmpSession) 84 | session.stop(); 85 | }); 86 | } 87 | } 88 | 89 | module.exports = NodeRtmpServer; 90 | -------------------------------------------------------------------------------- /src/node_core_utils.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 17/8/23. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const Crypto = require('crypto'); 7 | const { spawn } = require('child_process'); 8 | const context = require('./node_core_ctx'); 9 | 10 | function generateNewSessionID() { 11 | let sessionID = ''; 12 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWKYZ0123456789'; 13 | const numPossible = possible.length; 14 | do { 15 | for (let i = 0; i < 8; i++) { 16 | sessionID += possible.charAt((Math.random() * numPossible) | 0); 17 | } 18 | } while (context.sessions.has(sessionID)); 19 | return sessionID; 20 | } 21 | 22 | function genRandomName() { 23 | let name = ''; 24 | const possible = 'abcdefghijklmnopqrstuvwxyz0123456789'; 25 | const numPossible = possible.length; 26 | for (let i = 0; i < 4; i++) { 27 | name += possible.charAt((Math.random() * numPossible) | 0); 28 | } 29 | 30 | return name; 31 | } 32 | 33 | function verifyAuth(signStr, streamId, secretKey) { 34 | if (signStr === undefined) { 35 | return false; 36 | } 37 | let now = Date.now() / 1000 | 0; 38 | let exp = parseInt(signStr.split('-')[0]); 39 | let shv = signStr.split('-')[1]; 40 | let str = streamId + '-' + exp + '-' + secretKey; 41 | if (exp < now) { 42 | return false; 43 | } 44 | let md5 = Crypto.createHash('md5'); 45 | let ohv = md5.update(str).digest('hex'); 46 | return shv === ohv; 47 | } 48 | 49 | function getFFmpegVersion(ffpath) { 50 | return new Promise((resolve, reject) => { 51 | let ffmpeg_exec = spawn(ffpath, ['-version']); 52 | let version = ''; 53 | ffmpeg_exec.on('error', (e) => { 54 | reject(e); 55 | }); 56 | ffmpeg_exec.stdout.on('data', (data) => { 57 | try { 58 | version = data.toString().split(/(?:\r\n|\r|\n)/g)[0].split('\ ')[2]; 59 | resolve(version); 60 | } catch (e) { 61 | } 62 | }); 63 | ffmpeg_exec.on('close', (code) => { 64 | }); 65 | }); 66 | } 67 | 68 | function getFFmpegUrl() { 69 | let url = ''; 70 | switch (process.platform) { 71 | case 'darwin': 72 | url = 'https://ffmpeg.zeranoe.com/builds/macos64/static/ffmpeg-latest-macos64-static.zip'; 73 | break; 74 | case 'win32': 75 | url = 'https://ffmpeg.zeranoe.com/builds/win64/static/ffmpeg-latest-win64-static.zip | https://ffmpeg.zeranoe.com/builds/win32/static/ffmpeg-latest-win32-static.zip'; 76 | break; 77 | case 'linux': 78 | url = 'https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz | https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-i686-static.tar.xz'; 79 | break; 80 | default: 81 | url = 'http://ffmpeg.org/download.html'; 82 | break; 83 | } 84 | return url; 85 | } 86 | 87 | module.exports = { 88 | generateNewSessionID, 89 | verifyAuth, 90 | genRandomName, 91 | getFFmpegVersion, 92 | getFFmpegUrl 93 | }; 94 | -------------------------------------------------------------------------------- /src/node_media_server.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 17/8/1. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | 7 | const Logger = require('./node_core_logger'); 8 | const NodeRtmpServer = require('./node_rtmp_server'); 9 | const NodeHttpServer = require('./node_http_server'); 10 | const NodeTransServer = require('./node_trans_server'); 11 | const NodeRelayServer = require('./node_relay_server'); 12 | const NodeFissionServer = require('./node_fission_server'); 13 | const context = require('./node_core_ctx'); 14 | 15 | const Package = require('../package.json'); 16 | class NodeMediaServer { 17 | constructor(config) { 18 | this.config = config; 19 | } 20 | 21 | run() { 22 | Logger.setLogType(this.config.logType); 23 | Logger.log(`Guac Media Server v${Package.version}`); 24 | 25 | if (this.config.rtmp) { 26 | this.nrs = new NodeRtmpServer(this.config); 27 | this.nrs.run(); 28 | } 29 | 30 | if (this.config.http) { 31 | this.nhs = new NodeHttpServer(this.config); 32 | this.nhs.run(); 33 | } 34 | 35 | if (this.config.trans) { 36 | if (this.config.cluster) { 37 | Logger.log('NodeTransServer does not work in cluster mode'); 38 | } else { 39 | this.nts = new NodeTransServer(this.config); 40 | this.nts.run(); 41 | } 42 | } 43 | 44 | if (this.config.relay) { 45 | if (this.config.cluster) { 46 | Logger.log('NodeRelayServer does not work in cluster mode'); 47 | } else { 48 | this.nls = new NodeRelayServer(this.config); 49 | this.nls.run(); 50 | } 51 | } 52 | 53 | Logger.log('Overwriting uncaughtException handler - node-media-server!'); 54 | 55 | if (this.config.fission) { 56 | if (this.config.cluster) { 57 | Logger.log('NodeFissionServer does not work in cluster mode'); 58 | } else { 59 | this.nfs = new NodeFissionServer(this.config); 60 | this.nfs.run(); 61 | } 62 | } 63 | 64 | process.on('uncaughtException', function (err) { 65 | Logger.error('uncaughtException', err); 66 | }); 67 | 68 | process.on('SIGINT', function() { 69 | process.exit(); 70 | }); 71 | } 72 | 73 | on(eventName, listener) { 74 | context.nodeEvent.on(eventName, listener); 75 | } 76 | 77 | events() { return context.nodeEvent; } 78 | 79 | stop() { 80 | if (this.nrs) { this.nrs.stop(); } 81 | if (this.nhs) { this.nhs.stop(); } 82 | if (this.nts) { this.nts.stop(); } 83 | if (this.nls) { this.nls.stop(); } 84 | } 85 | 86 | getSession(id) { 87 | return context.sessions.get(id); 88 | } 89 | 90 | getSessionInfo() { 91 | let info = { 92 | rtmp: 0, 93 | http: 0, 94 | ws: 0 95 | }; 96 | for (let session of context.sessions.values()) { 97 | info.rtmp += session.TAG === 'rtmp' ? 1 : 0; 98 | info.http += session.TAG === 'http-flv' ? 1 : 0; 99 | info.ws += session.TAG === 'websocket-flv' ? 1 : 0; 100 | } 101 | return info; 102 | } 103 | } 104 | 105 | module.exports = NodeMediaServer; -------------------------------------------------------------------------------- /src/node_fission_server.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Chen Mingliang on 20/7/16. 3 | // illuspas[a]msn.com 4 | // Copyright (c) 2020 Nodemedia. All rights reserved. 5 | // 6 | const Logger = require('./node_core_logger'); 7 | 8 | const NodeFissionSession = require('./node_fission_session'); 9 | const context = require('./node_core_ctx'); 10 | const { getFFmpegVersion, getFFmpegUrl } = require('./node_core_utils'); 11 | const fs = require('fs'); 12 | const _ = require('lodash'); 13 | const mkdirp = require('mkdirp'); 14 | 15 | class NodeFissionServer { 16 | constructor(config) { 17 | this.config = config; 18 | this.fissionSessions = new Map(); 19 | } 20 | 21 | async run() { 22 | try { 23 | mkdirp.sync(this.config.http.mediaroot); 24 | fs.accessSync(this.config.http.mediaroot, fs.constants.W_OK); 25 | } catch (error) { 26 | Logger.error(`Node Media Fission Server startup failed. MediaRoot:${this.config.http.mediaroot} cannot be written.`); 27 | return; 28 | } 29 | 30 | try { 31 | fs.accessSync(this.config.fission.ffmpeg, fs.constants.X_OK); 32 | } catch (error) { 33 | Logger.error(`Node Media Fission Server startup failed. ffmpeg:${this.config.fission.ffmpeg} cannot be executed.`); 34 | return; 35 | } 36 | 37 | let version = await getFFmpegVersion(this.config.fission.ffmpeg); 38 | if (version === '' || parseInt(version.split('.')[0]) < 4) { 39 | Logger.error('Node Media Fission Server startup failed. ffmpeg requires version 4.0.0 above'); 40 | Logger.error('Download the latest ffmpeg static program:', getFFmpegUrl()); 41 | return; 42 | } 43 | 44 | context.nodeEvent.on('postPublish', this.onPostPublish.bind(this)); 45 | context.nodeEvent.on('donePublish', this.onDonePublish.bind(this)); 46 | Logger.log(`Node Media Fission Server started, MediaRoot: ${this.config.http.mediaroot}, ffmpeg version: ${version}`); 47 | } 48 | 49 | onPostPublish(id, streamPath, args) { 50 | let regRes = /\/(.*)\/(.*)/gi.exec(streamPath); 51 | let [app, name] = _.slice(regRes, 1); 52 | for (let task of this.config.fission.tasks) { 53 | regRes = /(.*)\/(.*)/gi.exec(task.rule); 54 | let [ruleApp, ruleName] = _.slice(regRes, 1); 55 | if ((app === ruleApp || ruleApp === '*') && (name === ruleName || ruleName === '*')) { 56 | let s = context.sessions.get(id); 57 | if (s.isLocal && name.split('_')[1]) { 58 | continue; 59 | } 60 | let conf = task; 61 | conf.ffmpeg = this.config.fission.ffmpeg; 62 | conf.mediaroot = this.config.http.mediaroot; 63 | conf.rtmpPort = this.config.rtmp.port; 64 | conf.streamPath = streamPath; 65 | conf.streamApp = app; 66 | conf.streamName = name; 67 | conf.args = args; 68 | let session = new NodeFissionSession(conf); 69 | this.fissionSessions.set(id, session); 70 | session.on('end', () => { 71 | this.fissionSessions.delete(id); 72 | }); 73 | session.run(); 74 | } 75 | } 76 | } 77 | 78 | onDonePublish(id, streamPath, args) { 79 | let session = this.fissionSessions.get(id); 80 | if (session) { 81 | session.end(); 82 | } 83 | } 84 | } 85 | 86 | module.exports = NodeFissionServer; 87 | -------------------------------------------------------------------------------- /archive.js: -------------------------------------------------------------------------------- 1 | const config = require('./misc/config'); 2 | const aws = require('aws-sdk'); 3 | 4 | aws.config.accessKeyId = config.s3.accessKey; 5 | aws.config.secretAccessKey = config.s3.secret; 6 | aws.config.logger = console; 7 | 8 | // Fix for Linode object storage error 9 | aws.util.update(aws.S3.prototype, { 10 | addExpect100Continue: function addExpect100Continue(req) { 11 | console.log('Depreciating this workaround, because introduced a bug'); 12 | console.log('Check: https://github.com/andrewrk/node-s3-client/issues/74'); 13 | } 14 | }); 15 | 16 | const s3 = new aws.S3({ 17 | endpoint: config.s3.endpoint 18 | }); 19 | const fs = require('fs'); 20 | const axios = require('axios'); 21 | const Promise = require('bluebird').Promise; 22 | 23 | const random = process.argv[2]; 24 | const streamName = process.argv[3]; 25 | const key = process.argv[4]; 26 | const duration = process.argv[5]; 27 | const ouPath = process.argv[6]; 28 | 29 | const upload = async data => { 30 | try { 31 | await s3.upload(data).promise(); 32 | } catch (e) { 33 | console.error(e); 34 | console.error('Retry: ' + data.Key); 35 | await upload(data); 36 | } 37 | }; 38 | 39 | const uploadThumb = async () => { 40 | try { 41 | const thumb = await axios.get(`http://${process.env['NMS_SERVER'] || 'lon.stream.guac.live'}/live/${streamName}/thumbnail.jpg?v=${Math.floor((new Date().getTime() - 15000) / 60000)}`, 42 | {responseType: 'arraybuffer'}); 43 | await upload({ 44 | Bucket: config.s3.bucket, 45 | Key: key + 'thumbnail.jpg', 46 | Body: thumb.data, 47 | ACL: 'public-read' 48 | }); 49 | } catch (e) { 50 | console.error(e); 51 | } 52 | }; 53 | 54 | const uploadVideos = async retry => { 55 | const promises = []; 56 | 57 | for (const filename of fs.readdirSync(ouPath)) { 58 | if (filename.endsWith('.ts') 59 | || filename.endsWith('.m3u8') 60 | || filename.endsWith('.mpd') 61 | || filename.endsWith('.m4s') 62 | || filename.endsWith('.tmp')) { 63 | const path = ouPath + '/' + filename; 64 | console.log(path); 65 | promises.push({ 66 | Bucket: config.s3.bucket, 67 | Key: key + filename, 68 | Body: fs.createReadStream(path), 69 | ACL: 'public-read' 70 | }); 71 | } 72 | } 73 | 74 | try { 75 | await Promise.map(promises, data => s3.upload(data).promise().then(() => fs.unlinkSync(data.Body.path)), {concurrency: config.s3.concurrency}); 76 | } catch (e) { 77 | console.error(e); 78 | await new Promise(resolve => setTimeout(resolve, 5000)); 79 | await uploadVideos(true); 80 | } 81 | 82 | if (retry) return; 83 | setTimeout(() => fs.rmdirSync(ouPath), 10000); 84 | axios.post( 85 | `${config.endpoint}/archive`, 86 | { 87 | streamName, 88 | duration, 89 | random, 90 | thumbnail: encodeURIComponent(`https://${config.s3.publishUrl}/${key}thumbnail.jpg`), 91 | stream: encodeURIComponent(`https://${config.s3.publishUrl}/${key}indexarchive.m3u8`) 92 | }, 93 | { 94 | headers: { 95 | 'Authorization': `Bearer ${config.api_secret}` 96 | } 97 | } 98 | ); 99 | }; 100 | 101 | (async () => { 102 | await uploadThumb(); 103 | await uploadVideos(false); 104 | })(); -------------------------------------------------------------------------------- /src/api/controllers/server.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 17/12/24. Merry Christmas 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | 7 | const OS = require('os'); 8 | const Package = require('../../../package.json'); 9 | function cpuAverage() { 10 | 11 | //Initialise sum of idle and time of cores and fetch CPU info 12 | let totalIdle = 0, totalTick = 0; 13 | let cpus = OS.cpus(); 14 | 15 | //Loop through CPU cores 16 | for (let i = 0, len = cpus.length; i < len; i++) { 17 | 18 | //Select CPU core 19 | let cpu = cpus[i]; 20 | 21 | //Total up the time in the cores tick 22 | for (type in cpu.times) { 23 | totalTick += cpu.times[type]; 24 | } 25 | 26 | //Total up the idle time of the core 27 | totalIdle += cpu.times.idle; 28 | } 29 | 30 | //Return the average Idle and Tick times 31 | return { idle: totalIdle / cpus.length, total: totalTick / cpus.length }; 32 | } 33 | 34 | function percentageCPU() { 35 | return new Promise(function (resolve, reject) { 36 | let startMeasure = cpuAverage(); 37 | setTimeout(() => { 38 | let endMeasure = cpuAverage(); 39 | //Calculate the difference in idle and total time between the measures 40 | let idleDifference = endMeasure.idle - startMeasure.idle; 41 | let totalDifference = endMeasure.total - startMeasure.total; 42 | 43 | //Calculate the average percentage CPU usage 44 | let percentageCPU = 100 - ~~(100 * idleDifference / totalDifference); 45 | resolve(percentageCPU); 46 | }, 100); 47 | }); 48 | } 49 | 50 | function getSessionsInfo(sessions) { 51 | let info = { 52 | inbytes: 0, 53 | outbytes: 0, 54 | rtmp: 0, 55 | http: 0, 56 | ws: 0, 57 | }; 58 | 59 | for (let session of sessions.values()) { 60 | if (session.TAG === 'relay') continue; 61 | let socket = session.TAG === 'rtmp' ? session.socket : session.req.socket; 62 | info.inbytes += socket.bytesRead; 63 | info.outbytes += socket.bytesWritten; 64 | info.rtmp += session.TAG === 'rtmp' ? 1 : 0; 65 | info.http += session.TAG === 'http-flv' ? 1 : 0; 66 | info.ws += session.TAG === 'websocket-flv' ? 1 : 0; 67 | } 68 | 69 | return info; 70 | } 71 | 72 | 73 | function getInfo(req, res, next) { 74 | let s = this.sessions; 75 | percentageCPU().then((cpuload) => { 76 | let sinfo = getSessionsInfo(s); 77 | let info = { 78 | os: { 79 | arch: OS.arch(), 80 | platform: OS.platform(), 81 | release: OS.release(), 82 | }, 83 | cpu: { 84 | num: OS.cpus().length, 85 | load: cpuload, 86 | model: OS.cpus()[0].model, 87 | speed: OS.cpus()[0].speed, 88 | }, 89 | mem: { 90 | total: OS.totalmem(), 91 | free: OS.freemem() 92 | }, 93 | net: { 94 | inbytes: this.stat.inbytes + sinfo.inbytes, 95 | outbytes: this.stat.outbytes + sinfo.outbytes, 96 | }, 97 | nodejs: { 98 | uptime: Math.floor(process.uptime()), 99 | version: process.version, 100 | mem: process.memoryUsage() 101 | }, 102 | clients: { 103 | accepted: this.stat.accepted, 104 | active: this.sessions.size - this.idlePlayers.size, 105 | idle: this.idlePlayers.size, 106 | rtmp: sinfo.rtmp, 107 | http: sinfo.http, 108 | ws: sinfo.ws 109 | }, 110 | version: Package.version 111 | }; 112 | res.json(info); 113 | }); 114 | } 115 | 116 | exports.getInfo = getInfo; 117 | -------------------------------------------------------------------------------- /src/node_rtmp_handshake.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 17/8/1. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const Crypto = require('crypto'); 7 | 8 | const MESSAGE_FORMAT_0 = 0; 9 | const MESSAGE_FORMAT_1 = 1; 10 | const MESSAGE_FORMAT_2 = 2; 11 | 12 | const RTMP_SIG_SIZE = 1536; 13 | const SHA256DL = 32; 14 | 15 | const RandomCrud = Buffer.from([ 16 | 0xf0, 0xee, 0xc2, 0x4a, 0x80, 0x68, 0xbe, 0xe8, 17 | 0x2e, 0x00, 0xd0, 0xd1, 0x02, 0x9e, 0x7e, 0x57, 18 | 0x6e, 0xec, 0x5d, 0x2d, 0x29, 0x80, 0x6f, 0xab, 19 | 0x93, 0xb8, 0xe6, 0x36, 0xcf, 0xeb, 0x31, 0xae 20 | ]); 21 | 22 | const GenuineFMSConst = 'Genuine Adobe Flash Media Server 001'; 23 | const GenuineFMSConstCrud = Buffer.concat([Buffer.from(GenuineFMSConst, 'utf8'), RandomCrud]); 24 | 25 | const GenuineFPConst = 'Genuine Adobe Flash Player 001'; 26 | const GenuineFPConstCrud = Buffer.concat([Buffer.from(GenuineFPConst, 'utf8'), RandomCrud]); 27 | 28 | function calcHmac(data, key) { 29 | let hmac = Crypto.createHmac('sha256', key); 30 | hmac.update(data); 31 | return hmac.digest(); 32 | } 33 | 34 | function GetClientGenuineConstDigestOffset(buf) { 35 | let offset = buf[0] + buf[1] + buf[2] + buf[3]; 36 | offset = (offset % 728) + 12; 37 | return offset; 38 | } 39 | 40 | function GetServerGenuineConstDigestOffset(buf) { 41 | let offset = buf[0] + buf[1] + buf[2] + buf[3]; 42 | offset = (offset % 728) + 776; 43 | return offset; 44 | } 45 | 46 | function detectClientMessageFormat(clientsig) { 47 | let computedSignature, msg, providedSignature, sdl; 48 | sdl = GetServerGenuineConstDigestOffset(clientsig.slice(772, 776)); 49 | msg = Buffer.concat([clientsig.slice(0, sdl), clientsig.slice(sdl + SHA256DL)], 1504); 50 | computedSignature = calcHmac(msg, GenuineFPConst); 51 | providedSignature = clientsig.slice(sdl, sdl + SHA256DL); 52 | if (computedSignature.equals(providedSignature)) { 53 | return MESSAGE_FORMAT_2; 54 | } 55 | sdl = GetClientGenuineConstDigestOffset(clientsig.slice(8, 12)); 56 | msg = Buffer.concat([clientsig.slice(0, sdl), clientsig.slice(sdl + SHA256DL)], 1504); 57 | computedSignature = calcHmac(msg, GenuineFPConst); 58 | providedSignature = clientsig.slice(sdl, sdl + SHA256DL); 59 | if (computedSignature.equals(providedSignature)) { 60 | return MESSAGE_FORMAT_1; 61 | } 62 | return MESSAGE_FORMAT_0; 63 | } 64 | 65 | function generateS1(messageFormat) { 66 | let randomBytes = Crypto.randomBytes(RTMP_SIG_SIZE - 8); 67 | let handshakeBytes = Buffer.concat([Buffer.from([0, 0, 0, 0, 1, 2, 3, 4]), randomBytes], RTMP_SIG_SIZE); 68 | 69 | let serverDigestOffset; 70 | if (messageFormat === 1) { 71 | serverDigestOffset = GetClientGenuineConstDigestOffset(handshakeBytes.slice(8, 12)); 72 | } else { 73 | serverDigestOffset = GetServerGenuineConstDigestOffset(handshakeBytes.slice(772, 776)); 74 | } 75 | 76 | let msg = Buffer.concat([handshakeBytes.slice(0, serverDigestOffset), handshakeBytes.slice(serverDigestOffset + SHA256DL)], RTMP_SIG_SIZE - SHA256DL); 77 | let hash = calcHmac(msg, GenuineFMSConst); 78 | hash.copy(handshakeBytes, serverDigestOffset, 0, 32); 79 | return handshakeBytes; 80 | } 81 | 82 | function generateS2(messageFormat, clientsig, callback) { 83 | let randomBytes = Crypto.randomBytes(RTMP_SIG_SIZE - 32); 84 | let challengeKeyOffset; 85 | if (messageFormat === 1) { 86 | challengeKeyOffset = GetClientGenuineConstDigestOffset(clientsig.slice(8, 12)); 87 | } else { 88 | challengeKeyOffset = GetServerGenuineConstDigestOffset(clientsig.slice(772, 776)); 89 | } 90 | let challengeKey = clientsig.slice(challengeKeyOffset, challengeKeyOffset + 32); 91 | let hash = calcHmac(challengeKey, GenuineFMSConstCrud); 92 | let signature = calcHmac(randomBytes, hash); 93 | let s2Bytes = Buffer.concat([randomBytes, signature], RTMP_SIG_SIZE); 94 | return s2Bytes; 95 | } 96 | 97 | function generateS0S1S2(clientsig) { 98 | let clientType = Buffer.alloc(1, 3); 99 | let messageFormat = detectClientMessageFormat(clientsig); 100 | let allBytes; 101 | if (messageFormat === MESSAGE_FORMAT_0) { 102 | // Logger.debug('[rtmp handshake] using simple handshake.'); 103 | allBytes = Buffer.concat([clientType, clientsig, clientsig]); 104 | } else { 105 | // Logger.debug('[rtmp handshake] using complex handshake.'); 106 | allBytes = Buffer.concat([clientType, generateS1(messageFormat), generateS2(messageFormat, clientsig)]); 107 | } 108 | return allBytes; 109 | } 110 | 111 | module.exports = { generateS0S1S2 }; 112 | -------------------------------------------------------------------------------- /src/api/controllers/relay.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 19/4/11. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2019 Nodemedia. All rights reserved. 5 | // 6 | const { get, set } = require('lodash'); 7 | const Express = require('express'); 8 | const { once } = require('events'); 9 | 10 | /** 11 | * get all relay tasks 12 | * @param {Express.Request} req 13 | * @param {Express.Response} res 14 | * @param {*} next 15 | */ 16 | function getStreams(req, res, next) { 17 | let stats = {}; 18 | this.sessions.forEach(function (session, id) { 19 | if (session.constructor.name !== 'NodeRelaySession') { 20 | return; 21 | } 22 | 23 | let { app, name } = session.conf; 24 | 25 | if (!get(stats, [app, name])) { 26 | set(stats, [app, name], { 27 | relays: [], 28 | }); 29 | } 30 | 31 | stats[app][name]['relays'].push({ 32 | app: app, 33 | name: name, 34 | path: session.conf.inPath, 35 | url: session.conf.ouPath, 36 | mode: session.conf.mode, 37 | ts: session.ts, 38 | id: id, 39 | }); 40 | }); 41 | res.json(stats); 42 | } 43 | 44 | /** 45 | * get relay task by id 46 | * @param {Express.Request} req 47 | * @param {Express.Response} res 48 | * @param {*} next 49 | */ 50 | function getStreamByID(req, res, next) { 51 | const relaySession = Array.from(this.sessions.values()).filter( 52 | (session) => 53 | session.constructor.name === 'NodeRelaySession' && 54 | req.params.id === session.id 55 | ); 56 | const relays = relaySession.map((item) => ({ 57 | app: item.conf.app, 58 | name: item.conf.name, 59 | path: item.conf.inPath, 60 | url: item.conf.ouPath, 61 | mode: item.conf.mode, 62 | ts: item.ts, 63 | id: item.id, 64 | })); 65 | res.json(relays); 66 | } 67 | 68 | /** 69 | * get relay task by name 70 | * @param {Express.Request} req 71 | * @param {Express.Response} res 72 | * @param {*} next 73 | */ 74 | function getStreamByName(req, res, next) { 75 | const relaySession = Array.from(this.sessions.values()).filter( 76 | (session) => 77 | session.constructor.name === 'NodeRelaySession' && 78 | req.params.app === session.conf.app && 79 | req.params.name === session.conf.name 80 | ); 81 | const relays = relaySession.map((item) => ({ 82 | app: item.conf.app, 83 | name: item.conf.name, 84 | url: item.conf.ouPath, 85 | mode: item.conf.mode, 86 | ts: item.ts, 87 | id: item.id, 88 | })); 89 | res.json(relays); 90 | } 91 | 92 | /** 93 | * create relay url to url task 94 | * @param {Express.Request} req 95 | * @param {Express.Response} res 96 | * @param {*} next 97 | */ 98 | async function relayStream(req, res, next) { 99 | let path = req.body.path; 100 | let url = req.body.url; 101 | if (path && url) { 102 | process.nextTick(() => this.nodeEvent.emit('relayTask', path, url)); 103 | let ret = await once(this.nodeEvent, 'relayTaskDone'); 104 | res.send(ret[0]); 105 | } else { 106 | res.sendStatus(400); 107 | } 108 | } 109 | 110 | 111 | /** 112 | * create relay pull task 113 | * @param {Express.Request} req 114 | * @param {Express.Response} res 115 | * @param {*} next 116 | */ 117 | async function pullStream(req, res, next) { 118 | let url = req.body.url; 119 | let app = req.body.app; 120 | let name = req.body.name; 121 | let rtsp_transport = req.body.rtsp_transport ? req.body.rtsp_transport : null; 122 | if (url && app && name) { 123 | process.nextTick(() => this.nodeEvent.emit('relayPull', url, app, name, rtsp_transport)); 124 | let ret = await once(this.nodeEvent, 'relayPullDone'); 125 | res.send(ret[0]); 126 | 127 | } else { 128 | res.sendStatus(400); 129 | } 130 | } 131 | 132 | /** 133 | * create relay push task 134 | * @param {Express.Request} req 135 | * @param {Express.Response} res 136 | * @param {*} next 137 | */ 138 | async function pushStream(req, res, next) { 139 | let url = req.body.url; 140 | let app = req.body.app; 141 | let name = req.body.name; 142 | if (url && app && name) { 143 | process.nextTick(() => this.nodeEvent.emit('relayPush', url, app, name)); 144 | let ret = await once(this.nodeEvent, 'relayPushDone'); 145 | res.send(ret[0]); 146 | } else { 147 | res.sendStatus(400); 148 | } 149 | } 150 | 151 | /** 152 | * delete relay task 153 | * @param {Express.Request} req 154 | * @param {Express.Response} res 155 | * @param {*} next 156 | */ 157 | function delStream(req, res, next) { 158 | let relaySession = this.sessions.get(req.params.id); 159 | if (relaySession) { 160 | relaySession.end(); 161 | res.sendStatus(200); 162 | } else { 163 | res.sendStatus(404); 164 | } 165 | } 166 | 167 | module.exports = { 168 | getStreams, 169 | getStreamByID, 170 | getStreamByName, 171 | relayStream, 172 | pullStream, 173 | pushStream, 174 | delStream, 175 | }; 176 | -------------------------------------------------------------------------------- /misc/index.js: -------------------------------------------------------------------------------- 1 | const NodeMediaServer = require('../src/node_media_server'); 2 | const axios = require('axios'); 3 | const cron = require('node-cron') 4 | // eslint-disable-next-line import/no-unresolved 5 | const helpers = require('./utils/helpers'); 6 | const Logger = require('../src/node_core_logger'); 7 | const conf = require('./config'); 8 | const path = require('path'); 9 | 10 | const IS_DEBUG = false;//process.env.NODE_ENV === 'development'; 11 | 12 | const config = { 13 | logType: IS_DEBUG ? 4 : 2, 14 | hostServer: process.env['NMS_SERVER'] || 'lon.stream.guac.live', 15 | auth: { 16 | api: true, 17 | api_user: conf.api_user, 18 | api_pass: conf.api_pass 19 | }, 20 | rtmp: { 21 | port: 1935, 22 | chunk_size: 100000, 23 | gop_cache: false, 24 | ping: 60, 25 | ping_timeout: 30 26 | }, 27 | http: { 28 | port: conf.http_port, 29 | allow_origin: '*', 30 | mediaroot: path.resolve(__dirname+'/../media'), 31 | recroot: path.resolve(__dirname+'/../rec'), 32 | }, 33 | websocket: false, 34 | misc: { 35 | api_endpoint: conf.endpoint, 36 | api_secret: conf.api_secret, 37 | ignore_auth: !!IS_DEBUG, 38 | maxDataRate: conf.maxDataRate || 8000, 39 | dataRateCheckInterval: conf.dataRateCheckInterval || 3, 40 | dataRateCheckCount: conf.dataRateCheckCount || 5, 41 | transcode: conf.transcode, 42 | archive: conf.archive, 43 | generateThumbnail: conf.generateThumbnail, 44 | } 45 | }; 46 | 47 | if (conf.https_port) { 48 | config.https = { 49 | port: conf.https_port, 50 | cert: conf.https_cert, 51 | key: conf.https_key 52 | }; 53 | } 54 | 55 | if (conf.ffmpeg_path) { 56 | const transcodeTasks = require('../misc/utils/transcode'); 57 | const tasks = [ 58 | // source quality 59 | { 60 | app: 'live', 61 | ac: 'copy', 62 | vc: 'copy', 63 | hls: true, 64 | hlsFlags: 'hls_time=1:hls_list_size=5:hls_flags=delete_segments+program_date_time' 65 | } 66 | ]; 67 | if(conf.archive){ 68 | tasks.push( 69 | { 70 | app: 'live', 71 | ac: 'copy', 72 | vc: 'copy', 73 | hls: true, 74 | rec: true, 75 | hlsFlags: 'hls_time=15:hls_list_size=0' 76 | }); 77 | } 78 | const combinedTasks = config.misc.transcode ? Object.assign(tasks, transcodeTasks) : tasks; 79 | 80 | config.trans = { 81 | ffmpeg: conf.ffmpeg_path, 82 | tasks: combinedTasks 83 | }; 84 | } 85 | 86 | const nms = new NodeMediaServer(config); 87 | nms.run(); 88 | 89 | nms.on('onMetaData', (id, metadata) => { 90 | console.log('onMetaData', id, metadata); 91 | let session = nms.getSession(id); 92 | if(metadata.videodatarate > config.misc.maxDataRate){ 93 | Logger.error('Bitrate too high', `${Math.round(Math.floor(metadata.videodatarate))}/${config.misc.maxDataRate} kbps (max).`); 94 | session.sendStatusMessage( 95 | session.publishStreamId, 96 | 'error', 97 | 'NetStream.Publish.Rejected', 98 | `Bitrate too high, ${Math.round(Math.floor(metadata.videodatarate))}/${config.misc.maxDataRate} kbps (max).` 99 | ); 100 | return session.reject(); 101 | } 102 | }); 103 | 104 | nms.on('postPublish', (id, StreamPath, args) => { 105 | let session = nms.getSession(id) 106 | console.log('[NodeEvent on postPublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 107 | if(config.misc.generateThumbnail){ 108 | // Create a thumbnail 109 | try{ 110 | helpers.generateStreamThumbnail(session.publishStreamPath); 111 | }catch(e){ 112 | } 113 | 114 | // Generate a thumbnail every 60 seconds 115 | try{ 116 | let task = cron.schedule('* * * * *', () => { 117 | helpers.generateStreamThumbnail(session.publishStreamPath) 118 | }, { 119 | scheduled: false 120 | }); 121 | // Save tasks in the session so we can stop it later 122 | session.task = task; 123 | // Start the tasks 124 | task.start(); 125 | }catch(e){ 126 | } 127 | } 128 | }); 129 | 130 | nms.on('donePublish', (id, StreamPath, args) => { 131 | let session = nms.getSession(id) 132 | 133 | // Stop thumbnail generation cron 134 | if(session.task) session.task.stop(); 135 | // Remove thumbnail 136 | try{ 137 | helpers.removeStreamThumbnail(session.publishStreamPath); 138 | }catch(e){ 139 | } 140 | axios.post( 141 | `${config.misc.api_endpoint}/live/on_publish_done`, 142 | `name=${args.token}&streamServer=${config.hostServer}&tcUrl=${StreamPath}`, { 143 | maxRedirects: 0, 144 | validateStatus: function (status) { 145 | // Bypass redirect 146 | return status == 304 || (status >= 200 && status < 300); 147 | }, 148 | headers: { 149 | Authorization: `Bearer ${config.misc.api_secret}`, 150 | 'Content-Type': 'application/x-www-form-urlencoded' 151 | } 152 | }) 153 | .then(response => { 154 | // eslint-disable-next-line no-console 155 | Logger.log(`[rtmp donePublish] id=${id} streamPath=${StreamPath} args=${JSON.stringify(args)} `); 156 | }) 157 | .catch(error => { 158 | // eslint-disable-next-line no-console 159 | Logger.error('[rtmp donePublish]', error); 160 | }); 161 | }); -------------------------------------------------------------------------------- /src/api/controllers/streams.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const NodeTransServer = require("../../node_trans_server"); 3 | 4 | function postStreamTrans(req, res, next) { 5 | let config = req.body; 6 | if ( 7 | config.app && 8 | config.hls && 9 | config.ac && 10 | config.vc && 11 | config.hlsFlags && 12 | config.dash && 13 | config.dashFlags 14 | ) { 15 | transServer = new NodeTransServer(config); 16 | if (transServer) { 17 | res.json({ message: "OK Success" }); 18 | } else { 19 | res.status(404); 20 | res.json({ message: "Failed creating stream" }); 21 | } 22 | } else { 23 | res.status(404); 24 | res.json({ message: "Failed creating stream" }); 25 | } 26 | } 27 | 28 | function getStreams(req, res, next) { 29 | let stats = {}; 30 | 31 | this.sessions.forEach(function(session, id) { 32 | if (session.isStarting) { 33 | let regRes = /\/(.*)\/(.*)/gi.exec( 34 | session.publishStreamPath || session.playStreamPath 35 | ); 36 | 37 | if (regRes === null) return; 38 | 39 | let [app, stream] = _.slice(regRes, 1); 40 | 41 | if (!_.get(stats, [app, stream])) { 42 | _.setWith(stats, [app, stream], { 43 | publisher: null, 44 | subscribers: [] 45 | }, Object); 46 | } 47 | 48 | switch (true) { 49 | case session.isPublishing: { 50 | _.setWith(stats, [app, stream, 'publisher'], { 51 | app: app, 52 | stream: stream, 53 | clientId: session.id, 54 | connectCreated: session.connectTime, 55 | bytes: session.socket.bytesRead, 56 | ip: session.ip || session.socket.remoteAddress, 57 | audio: 58 | session.audioCodec > 0 59 | ? { 60 | codec: session.audioCodecName, 61 | profile: session.audioProfileName, 62 | samplerate: session.audioSamplerate, 63 | channels: session.audioChannels 64 | } 65 | : null, 66 | video: 67 | session.videoCodec > 0 68 | ? { 69 | codec: session.videoCodecName, 70 | width: session.videoWidth, 71 | height: session.videoHeight, 72 | profile: session.videoProfileName, 73 | level: session.videoLevel, 74 | fps: session.videoFps 75 | } 76 | : null 77 | }); 78 | 79 | break; 80 | } 81 | case !!session.playStreamPath: { 82 | switch (session.constructor.name) { 83 | case "NodeRtmpSession": { 84 | stats[app][stream]["subscribers"].push({ 85 | app: app, 86 | stream: stream, 87 | clientId: session.id, 88 | connectCreated: session.connectTime, 89 | bytes: session.socket.bytesWritten, 90 | ip: session.ip || session.socket.remoteAddress, 91 | protocol: 'rtmp' 92 | }); 93 | 94 | break; 95 | } 96 | case "NodeFlvSession": { 97 | stats[app][stream]["subscribers"].push({ 98 | app: app, 99 | stream: stream, 100 | clientId: session.id, 101 | connectCreated: session.connectTime, 102 | bytes: session.req.connection.bytesWritten, 103 | ip: session.ip || session.req.connection.remoteAddress, 104 | protocol: session.TAG === 'websocket-flv' ? 'ws' : 'http' 105 | }); 106 | 107 | break; 108 | } 109 | } 110 | 111 | break; 112 | } 113 | } 114 | } 115 | }); 116 | res.json(stats); 117 | } 118 | 119 | function getStream(req, res, next) { 120 | let streamStats = { 121 | isLive: false, 122 | viewers: 0, 123 | duration: 0, 124 | bitrate: 0, 125 | startTime: null, 126 | arguments: {} 127 | }; 128 | 129 | let publishStreamPath = `/${req.params.app}/${req.params.stream}`; 130 | 131 | let publisherSession = this.sessions.get( 132 | this.publishers.get(publishStreamPath) 133 | ); 134 | 135 | streamStats.isLive = !!publisherSession; 136 | streamStats.viewers = _.filter( 137 | Array.from(this.sessions.values()), 138 | session => { 139 | return session.playStreamPath === publishStreamPath; 140 | } 141 | ).length; 142 | streamStats.duration = streamStats.isLive 143 | ? Math.ceil((Date.now() - publisherSession.startTimestamp) / 1000) 144 | : 0; 145 | streamStats.bitrate = 146 | streamStats.duration > 0 ? publisherSession.bitrate : 0; 147 | streamStats.startTime = streamStats.isLive 148 | ? publisherSession.connectTime 149 | : null; 150 | streamStats.arguments = !!publisherSession ? publisherSession.publishArgs : {}; 151 | 152 | res.json(streamStats); 153 | } 154 | 155 | function delStream(req, res, next) { 156 | let publishStreamPath = `/${req.params.app}/${req.params.stream}`; 157 | let publisherSession = this.sessions.get( 158 | this.publishers.get(publishStreamPath) 159 | ); 160 | 161 | if (publisherSession) { 162 | publisherSession.stop(); 163 | res.json("ok"); 164 | } else { 165 | res.json({ error: "stream not found" }, 404); 166 | } 167 | } 168 | 169 | exports.delStream = delStream; 170 | exports.getStreams = getStreams; 171 | exports.getStream = getStream; 172 | exports.postStreamTrans = postStreamTrans; 173 | -------------------------------------------------------------------------------- /src/api/controllers/clip.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Thomas Lekanger on 4/4/2021. 3 | // datagutt[a]guac.live 4 | // Copyright (c) 2021 guac.live. All rights reserved. 5 | // 6 | const { 7 | spawn 8 | } = require('child_process'); 9 | const path = require('path'); 10 | const _ = require('lodash'); 11 | 12 | const HLS = require('hls-parser'); 13 | 14 | const axios = require('axios'); 15 | 16 | const Logger = require('../../node_core_logger'); 17 | 18 | const HLS_TIME = 15; 19 | const config = require('../../../misc/config'); 20 | const aws = require('aws-sdk'); 21 | 22 | aws.config.accessKeyId = config.s3.accessKey; 23 | aws.config.secretAccessKey = config.s3.secret; 24 | aws.config.logger = console; 25 | 26 | // Fix for Linode object storage error 27 | aws.util.update(aws.S3.prototype, { 28 | addExpect100Continue: function addExpect100Continue(req) { 29 | console.log('Depreciating this workaround, because introduced a bug'); 30 | console.log('Check: https://github.com/andrewrk/node-s3-client/issues/74'); 31 | } 32 | }); 33 | 34 | const s3 = new aws.S3({ 35 | endpoint: config.s3.endpoint 36 | }); 37 | const fs = require('fs'); 38 | 39 | const getM3u8 = async (url) => { 40 | let data; 41 | await axios 42 | .get(url) 43 | .then((response) => { 44 | data = response.data; 45 | }) 46 | .catch(async (e) => { 47 | if (!e.response) { 48 | return console.error(e); 49 | } 50 | console.error(e.response.data); 51 | }); 52 | return data; 53 | }; 54 | 55 | const upload = async data => { 56 | try { 57 | await s3.upload(data).promise(); 58 | } catch (e) { 59 | console.error(e); 60 | console.error('Retry: ' + data.Key); 61 | await upload(data); 62 | } 63 | }; 64 | 65 | const uploadClip = async (key, fullPath) => { 66 | try { 67 | await upload({ 68 | Bucket: config.s3.bucket.replace('/stream-vods', '/stream-clips'), 69 | Key: key, 70 | Body: fs.createReadStream(fullPath), 71 | ACL: 'public-read' 72 | }); 73 | } catch (e) { 74 | console.error(e); 75 | } 76 | }; 77 | 78 | function getArchiveSession(transSessions, streamName){ 79 | for (let session of transSessions.values()) { 80 | //console.log('getArchiveSession', session, session.conf); 81 | if( 82 | session && 83 | session.conf.streamName && 84 | session.conf.streamName === streamName && 85 | session.conf.rec 86 | ){ 87 | return session; 88 | } 89 | } 90 | return false; 91 | } 92 | 93 | async function clip(req, res, next) { 94 | let length = req.body.length; 95 | let name = req.body.name; 96 | let time = (new Date).getTime(); 97 | Logger.log('Clip route', length, name); 98 | if (length && name) { 99 | if (length <= 0 || length > 60){ 100 | res.status(400); 101 | res.send('Length too long'); 102 | return; 103 | } 104 | 105 | const archiveSession = getArchiveSession(this.transSessions, name); 106 | console.log(archiveSession); 107 | if (!archiveSession) { 108 | res.status(400); 109 | res.send('Stream is not being archived'); 110 | return; 111 | } 112 | 113 | this.nodeEvent.emit('clip', length, name); 114 | let archiveUrl = `http://${process.env['NMS_SERVER'] || 'lon.stream.guac.live'}/rec/live/${name}/${archiveSession.random}` 115 | let playlistUrl = `${archiveUrl}/indexarchive.m3u8`; 116 | let filename = `clip_${name}_${time}.mp4`; 117 | let fullPath = path.join(__dirname, '../../..', path.sep, 'rec', path.sep, 'live', path.sep, filename); 118 | 119 | const playlistData = await getM3u8(playlistUrl); 120 | var playlist; 121 | try{ 122 | playlist = HLS.parse(playlistData); 123 | }catch(e){ 124 | res.status(500); 125 | return res.send('Invalid HLS playlist for archive') 126 | } 127 | let startSegment; 128 | // If we are at the start of a stream, we might not have enough segments for x sec 129 | if(playlist.segments.length < Math.floor(length / HLS_TIME)){ 130 | startSegment = 0; 131 | }else{ 132 | startSegment = playlist.segments.length - 1 - Math.floor(length / HLS_TIME); 133 | } 134 | let endSegment = playlist.segments.length - 1; 135 | let segments = playlist.segments.slice(startSegment, endSegment); 136 | let segmentUris = segments.map((segment) => { 137 | return `${archiveUrl}/${segment.uri}`; 138 | }) 139 | 140 | if(!segmentUris || segmentUris.length === 0){ 141 | res.status(500); 142 | return res.send('No segment URIs found') 143 | } 144 | 145 | const argv = [ 146 | '-protocol_whitelist', 'file,http,https,tcp,tls,concat', 147 | '-i', `concat:${segmentUris.join('|')}`, 148 | '-c', 'copy', 149 | '-bsf:a', 'aac_adtstoasc', 150 | '-f', 'mp4', 151 | fullPath 152 | ]; 153 | 154 | this.ffmpeg_exec = spawn(config.ffmpeg_path, argv); 155 | this.ffmpeg_exec.on('error', (e) => { 156 | Logger.ffdebug(e); 157 | }); 158 | 159 | this.ffmpeg_exec.stdout.on('data', (data) => { 160 | Logger.ffdebug(`ff clip out: ${data}`); 161 | }); 162 | 163 | this.ffmpeg_exec.stderr.on('data', (data) => { 164 | Logger.ffdebug(`FF clip err: ${data}`); 165 | }); 166 | 167 | this.ffmpeg_exec.on('close', async (code) => { 168 | await uploadClip(filename, fullPath); 169 | res.status(200); 170 | res.send({ 171 | filename, 172 | url: `https://${config.s3.publishUrl.replace('/stream-vods', '/stream-clips')}/${filename}`, 173 | time 174 | }); 175 | }); 176 | 177 | } else { 178 | res.sendStatus(400); 179 | } 180 | } 181 | 182 | module.exports = { 183 | clip 184 | }; 185 | -------------------------------------------------------------------------------- /src/node_http_server.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 17/8/1. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | 7 | 8 | const Fs = require('fs'); 9 | const path = require('path'); 10 | const Http = require('http'); 11 | const Https = require('https'); 12 | const WebSocket = require('ws'); 13 | const Express = require('express'); 14 | const bodyParser = require('body-parser'); 15 | const basicAuth = require('basic-auth-connect'); 16 | const NodeFlvSession = require('./node_flv_session'); 17 | const HTTP_PORT = 80; 18 | const HTTP_HOST = '0.0.0.0'; 19 | const HTTPS_PORT = 443; 20 | const HTTPS_HOST = '0.0.0.0'; 21 | const HTTP_MEDIAROOT = './media'; 22 | const HTTP_RECROOT = './rec'; 23 | const Logger = require('./node_core_logger'); 24 | const context = require('./node_core_ctx'); 25 | 26 | const misc = require('../misc/utils/helpers'); 27 | 28 | const streamsRoute = require('./api/routes/streams'); 29 | const serverRoute = require('./api/routes/server'); 30 | const relayRoute = require('./api/routes/relay'); 31 | const clipRoute = require('./api/routes/clip'); 32 | 33 | class NodeHttpServer { 34 | constructor(config) { 35 | this.port = config.http.port || HTTP_PORT; 36 | this.host = config.http.host || HTTP_HOST; 37 | this.mediaroot = config.http.mediaroot || HTTP_MEDIAROOT; 38 | this.recroot = config.http.recroot || HTTP_RECROOT; 39 | this.config = config; 40 | 41 | let app = Express(); 42 | app.use(Express.json()); 43 | 44 | app.use(Express.urlencoded({ extended: true })); 45 | 46 | app.all('*', (req, res, next) => { 47 | res.header('Access-Control-Allow-Origin', this.config.http.allow_origin); 48 | res.header('Access-Control-Allow-Headers', 'Content-Type,Content-Length, Authorization, Accept,X-Requested-With'); 49 | res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS'); 50 | res.header('Access-Control-Allow-Credentials', true); 51 | req.method === 'OPTIONS' ? res.sendStatus(200) : next(); 52 | }); 53 | 54 | app.get('*.flv', (req, res, next) => { 55 | req.nmsConnectionType = 'http'; 56 | this.onConnect(req, res); 57 | }); 58 | 59 | let adminEntry = path.join(__dirname + '/public/admin/index.html'); 60 | if (Fs.existsSync(adminEntry)) { 61 | app.get('/admin/*', (req, res) => { 62 | res.sendFile(adminEntry); 63 | }); 64 | } 65 | 66 | if (this.config.http.api !== false) { 67 | if (this.config.auth && this.config.auth.api) { 68 | app.use(['/api/*', '/static/*', '/admin/*'], basicAuth(this.config.auth.api_user, this.config.auth.api_pass)); 69 | } 70 | app.use('/api/streams', streamsRoute(context)); 71 | app.use('/api/server', serverRoute(context)); 72 | app.use('/api/relay', relayRoute(context)); 73 | app.use('/api/misc', misc.router(context)); 74 | app.use('/api/clip', clipRoute(context)); 75 | } 76 | 77 | app.use(Express.static(path.join(__dirname + '/public'))); 78 | app.use(Express.static(this.mediaroot)); 79 | app.use('/rec', Express.static(this.recroot)); 80 | if (config.http.webroot) { 81 | app.use(Express.static(config.http.webroot)); 82 | } 83 | 84 | this.httpServer = Http.createServer(app); 85 | 86 | /** 87 | * ~ openssl genrsa -out privatekey.pem 1024 88 | * ~ openssl req -new -key privatekey.pem -out certrequest.csr 89 | * ~ openssl x509 -req -in certrequest.csr -signkey privatekey.pem -out certificate.pem 90 | */ 91 | if (this.config.https) { 92 | let options = { 93 | key: Fs.readFileSync(this.config.https.key), 94 | cert: Fs.readFileSync(this.config.https.cert) 95 | }; 96 | this.sport = config.https.port ? config.https.port : HTTPS_PORT; 97 | this.shost = config.https.host ? config.https.host : HTTPS_HOST; 98 | this.httpsServer = Https.createServer(options, app); 99 | } 100 | } 101 | 102 | run() { 103 | this.httpServer.listen(this.port, this.host, () => { 104 | Logger.log(`Node Media Http Server started on: ${this.host}:${this.port}`); 105 | }); 106 | 107 | this.httpServer.on('error', (e) => { 108 | Logger.error(`Node Media Http Server ${e}`); 109 | }); 110 | 111 | this.httpServer.on('close', () => { 112 | Logger.log('Node Media Http Server Close.'); 113 | }); 114 | 115 | if (this.config.websocket) { 116 | this.wsServer = new WebSocket.Server({ server: this.httpServer }); 117 | 118 | this.wsServer.on('connection', (ws, req) => { 119 | req.nmsConnectionType = 'ws'; 120 | this.onConnect(req, ws); 121 | }); 122 | 123 | this.wsServer.on('listening', () => { 124 | Logger.log(`Node Media WebSocket Server started on: ${this.host}:${this.port}`); 125 | }); 126 | this.wsServer.on('error', (e) => { 127 | Logger.error(`Node Media WebSocket Server ${e}`); 128 | }); 129 | } 130 | 131 | if (this.httpsServer) { 132 | this.httpsServer.listen(this.sport, this.shost, () => { 133 | Logger.log(`Node Media Https Server started on: ${this.shost}:${this.sport}`); 134 | }); 135 | 136 | this.httpsServer.on('error', (e) => { 137 | Logger.error(`Node Media Https Server ${e}`); 138 | }); 139 | 140 | this.httpsServer.on('close', () => { 141 | Logger.log('Node Media Https Server Close.'); 142 | }); 143 | 144 | this.wssServer = new WebSocket.Server({ server: this.httpsServer }); 145 | 146 | this.wssServer.on('connection', (ws, req) => { 147 | req.nmsConnectionType = 'ws'; 148 | this.onConnect(req, ws); 149 | }); 150 | 151 | this.wssServer.on('listening', () => { 152 | Logger.log(`Node Media WebSocketSecure Server started on: ${this.shost}:${this.sport}`); 153 | }); 154 | this.wssServer.on('error', (e) => { 155 | Logger.error(`Node Media WebSocketSecure Server ${e}`); 156 | }); 157 | } 158 | 159 | context.nodeEvent.on('postPlay', (id, args) => { 160 | context.stat.accepted++; 161 | }); 162 | 163 | context.nodeEvent.on('postPublish', (id, args) => { 164 | context.stat.accepted++; 165 | }); 166 | 167 | context.nodeEvent.on('doneConnect', (id, args) => { 168 | let session = context.sessions.get(id); 169 | let socket = session instanceof NodeFlvSession ? session.req.socket : session.socket; 170 | context.stat.inbytes += socket.bytesRead; 171 | context.stat.outbytes += socket.bytesWritten; 172 | }); 173 | } 174 | 175 | stop() { 176 | this.httpServer.close(); 177 | if (this.httpsServer) { 178 | this.httpsServer.close(); 179 | } 180 | context.sessions.forEach((session, id) => { 181 | if (session instanceof NodeFlvSession) { 182 | session.stop(); 183 | session.req.destroy(); 184 | context.sessions.delete(id); 185 | } 186 | }); 187 | } 188 | 189 | onConnect(req, res) { 190 | let session = new NodeFlvSession(this.config, req, res); 191 | session.run(); 192 | } 193 | } 194 | 195 | module.exports = NodeHttpServer; 196 | -------------------------------------------------------------------------------- /src/node_trans_session.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 18/3/9. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const Logger = require('./node_core_logger'); 7 | 8 | const EventEmitter = require('events'); 9 | const { 10 | spawn 11 | } = require('child_process'); 12 | const dateFormat = require('dateformat'); 13 | const mkdirp = require('mkdirp'); 14 | const fs = require('fs'); 15 | 16 | const { extractProgress } = require('../misc/utils/helpers'); 17 | 18 | const isHlsFile = (filename) => filename.endsWith('.ts') || filename.endsWith('.m3u8') 19 | const isTempFiles = (filename) => filename.endsWith('.mpd') || filename.endsWith('.m4s') || filename.endsWith('.tmp') 20 | class NodeTransSession extends EventEmitter { 21 | constructor(conf) { 22 | super(); 23 | this.conf = conf; 24 | this.data = {}; 25 | this.getConfig = (key = null) => { 26 | if (!key) return 27 | if (typeof this.conf != 'object') return 28 | if (this.conf.args && typeof this.conf.args === 'object' && this.conf.args[key]) return this.conf.args[key] 29 | return this.conf[key] 30 | } 31 | } 32 | 33 | run() { 34 | let vc = this.conf.vc || 'copy'; 35 | let ac = this.conf.ac || 'copy'; 36 | let inPath = 'rtmp://127.0.0.1:' + this.conf.rtmpPort + this.conf.streamPath; 37 | let ouPath = `${this.conf.mediaroot}/${this.conf.streamApp}/${this.conf.streamName}`; 38 | let mapStr = ''; 39 | let analyzeDuration = this.conf.analyzeDuration || '1000000'; // used to be 2147483647 40 | let probeSize = this.conf.probeSize || '1000000'; // used to be 2147483647 41 | 42 | const start = new Date(); 43 | const random = this.random = [...Array(11)].map(i => (~~(Math.random() * 36)).toString(36)).join(''); 44 | ouPath += this.conf.rec ? `/${random}` : ''; 45 | if(this.conf.rec && !this.conf.name) this.conf.name = 'archive'; 46 | 47 | if (this.conf.rtmp && this.conf.rtmpApp) { 48 | if (this.conf.rtmpApp === this.conf.streamApp) { 49 | Logger.error('[Transmuxing RTMP] Cannot output to the same app.'); 50 | } else { 51 | let rtmpOutput = `rtmp://127.0.0.1:${this.conf.rtmpPort}/${this.conf.rtmpApp}/${this.conf.streamName}`; 52 | mapStr += `[f=flv]${rtmpOutput}|`; 53 | Logger.log('[Transmuxing RTMP] ' + this.conf.streamPath + ' to ' + rtmpOutput); 54 | } 55 | } 56 | if (this.conf.mp4) { 57 | this.conf.mp4Flags = this.conf.mp4Flags ? this.conf.mp4Flags : ''; 58 | let mp4FileName = dateFormat('yyyy-mm-dd-HH-MM-ss') + '.mp4'; 59 | let mapMp4 = `${this.conf.mp4Flags}${ouPath}/${mp4FileName}|`; 60 | mapStr += mapMp4; 61 | Logger.log('[Transmuxing MP4] ' + this.conf.streamPath + ' to ' + ouPath + '/' + mp4FileName); 62 | } 63 | if (this.conf.hls) { 64 | this.conf.hlsFlags = this.conf.hlsFlags ? this.conf.hlsFlags : ''; 65 | this.hlsFileName = this.conf.name ? `index${this.conf.name}.m3u8` : 'index.m3u8'; 66 | let mapHls = `[${this.conf.hlsFlags}:hls_segment_filename=\'${ouPath}/stream_${this.conf.name || 'index'}${this.conf.rec ? '' : `_${random}`}_%d.ts\']${ouPath}/${this.hlsFileName}|`; 67 | mapStr += mapHls; 68 | Logger.log('[Transmuxing HLS] ' + this.conf.streamPath + ' to ' + ouPath + '/' + this.hlsFileName); 69 | } 70 | if (this.conf.dash) { 71 | this.conf.dashFlags = this.conf.dashFlags ? this.conf.dashFlags : ''; 72 | let dashFileName = this.conf.name ? `index${this.conf.name}.mpd` : 'index.mpd'; 73 | let mapDash = `${this.conf.dashFlags}${ouPath}/${dashFileName}`; 74 | mapStr += mapDash; 75 | Logger.log('[Transmuxing DASH] ' + this.conf.streamPath + ' to ' + ouPath + '/' + dashFileName); 76 | } 77 | if (this.conf.flv) { 78 | this.conf.flvFlags = this.conf.flvFlags ? this.conf.flvFlags : ''; 79 | let flvFileName = this.conf.name ? `index${this.conf.name}.flv` : 'index.flv'; 80 | let mapFlv = `${this.conf.flvFlags}${ouPath}/${flvFileName}|`; 81 | mapStr += mapFlv; 82 | Logger.log('[Transmuxing FLV] ' + this.conf.streamPath + ' to ' + ouPath + '/' + flvFileName); 83 | } 84 | mkdirp.sync(ouPath); 85 | let argv = ['-y', '-flags', 'low_delay', '-fflags', 'nobuffer', '-analyzeduration', analyzeDuration, '-probesize', probeSize, '-i', inPath]; 86 | Array.prototype.push.apply(argv, ['-c:v', vc]); 87 | Array.prototype.push.apply(argv, this.conf.vcParam); 88 | Array.prototype.push.apply(argv, ['-c:a', ac]); 89 | Array.prototype.push.apply(argv, this.conf.acParam); 90 | if (this.conf.rec) { 91 | Array.prototype.push.apply(argv, ['-t', '14400']); 92 | } 93 | Array.prototype.push.apply(argv, ['-f', 'tee', '-map', '0:a?', '-map', '0:v?', mapStr]); 94 | argv = argv.filter((n) => { 95 | return n 96 | }); //去空 97 | this.ffmpeg_exec = spawn(this.conf.ffmpeg, argv); 98 | this.ffmpeg_exec.on('error', (e) => { 99 | Logger.ffdebug(e); 100 | }); 101 | 102 | this.ffmpeg_exec.stdout.on('data', (data) => { 103 | Logger.ffdebug(`FF输出:${data}`); 104 | }); 105 | 106 | this.ffmpeg_exec.stderr.on('data', (data) => { 107 | extractProgress(this, data.toString()); 108 | Logger.ffdebug(`FF输出:${data}`); 109 | }); 110 | 111 | this.ffmpeg_exec.on('close', (code) => { 112 | Logger.log('[Transmuxing end] ' + this.conf.streamPath); 113 | this.emit('end'); 114 | 115 | const date = new Date(); 116 | const key = `live/archives/${date.getFullYear()}_${(`0${date.getMonth() + 1}`).slice(-2)}/${random}/`; 117 | 118 | if (this.conf.rec) { 119 | console.log('recording: ' + 'node' + ' archive.js ' + random+ ' ' + this.conf.streamName + ' ' + key + ' ' + ((date - start) / 1000).toFixed() + ' ' + ouPath); 120 | const archive = spawn('node', ['archive.js', random, this.conf.streamName, key, ((date - start) / 1000).toFixed(), ouPath]); 121 | archive.stderr.pipe(process.stderr); 122 | archive.stdout.pipe(process.stdout); 123 | return; 124 | } 125 | 126 | 127 | this.cleanTempFiles(ouPath); 128 | this.deleteHlsFiles(ouPath); 129 | this.createEmptyHlsFile(ouPath); 130 | }); 131 | } 132 | 133 | end() { 134 | this.ffmpeg_exec.stdin.write('q'); 135 | } 136 | 137 | // delete hls files 138 | deleteHlsFiles (ouPath) { 139 | if ((!ouPath && !this.conf.hls) || this.getConfig('hlsKeep')) return 140 | fs.readdir(ouPath, function (err, files) { 141 | if (err) return 142 | files.filter((filename) => isHlsFile(filename)).forEach((filename) => { 143 | fs.unlinkSync(`${ouPath}/${filename}`); 144 | }); 145 | }); 146 | } 147 | 148 | // delete the other files 149 | cleanTempFiles (ouPath) { 150 | if (!ouPath) return 151 | fs.readdir(ouPath, function (err, files) { 152 | if (err) return 153 | files.filter((filename) => isTempFiles(filename)).forEach((filename) => { 154 | fs.unlinkSync(`${ouPath}/${filename}`); 155 | }); 156 | }); 157 | } 158 | 159 | // create an empty hls file 160 | createEmptyHlsFile (ouPath) { 161 | if (!ouPath) return 162 | try { 163 | fs.writeFileSync(ouPath + '/' + this.hlsFileName, '#EXTM3U\n'); 164 | } catch(e) {} 165 | } 166 | } 167 | module.exports = NodeTransSession; -------------------------------------------------------------------------------- /src/node_flv_session.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 17/8/4. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const URL = require('url'); 7 | const Logger = require('./node_core_logger'); 8 | const context = require('./node_core_ctx'); 9 | const NodeCoreUtils = require('./node_core_utils'); 10 | 11 | const FlvPacket = { 12 | create: (payload = null, type = 0, time = 0) => { 13 | return { 14 | header: { 15 | length: payload ? payload.length : 0, 16 | timestamp: time, 17 | type: type 18 | }, 19 | payload: payload 20 | }; 21 | } 22 | }; 23 | 24 | class NodeFlvSession { 25 | constructor(config, req, res) { 26 | this.config = config; 27 | this.req = req; 28 | this.res = res; 29 | this.id = NodeCoreUtils.generateNewSessionID(); 30 | this.ip = this.req.headers['x-forwarded-for'] || this.req.socket.remoteAddress; 31 | 32 | this.playStreamPath = ''; 33 | this.playArgs = null; 34 | 35 | this.isStarting = false; 36 | this.isPlaying = false; 37 | this.isIdling = false; 38 | 39 | if (this.req.nmsConnectionType === 'ws') { 40 | this.res.cork = this.res._socket.cork.bind(this.res._socket); 41 | this.res.uncork = this.res._socket.uncork.bind(this.res._socket); 42 | this.res.on('close', this.onReqClose.bind(this)); 43 | this.res.on('error', this.onReqError.bind(this)); 44 | this.res.write = this.res.send; 45 | this.res.end = this.res.close; 46 | this.TAG = 'websocket-flv'; 47 | } else { 48 | this.res.cork = this.res.socket.cork.bind(this.res.socket); 49 | this.res.uncork = this.res.socket.uncork.bind(this.res.socket); 50 | this.req.socket.on('close', this.onReqClose.bind(this)); 51 | this.req.on('error', this.onReqError.bind(this)); 52 | this.TAG = 'http-flv'; 53 | } 54 | 55 | this.numPlayCache = 0; 56 | context.sessions.set(this.id, this); 57 | } 58 | 59 | run() { 60 | let method = this.req.method; 61 | let urlInfo = URL.parse(this.req.url, true); 62 | let streamPath = urlInfo.pathname.split('.')[0]; 63 | this.connectCmdObj = { ip: this.ip, method, streamPath, query: urlInfo.query }; 64 | this.connectTime = new Date(); 65 | this.isStarting = true; 66 | Logger.log(`[${this.TAG} connect] id=${this.id} ip=${this.ip} args=${JSON.stringify(urlInfo.query)}`); 67 | context.nodeEvent.emit('preConnect', this.id, this.connectCmdObj); 68 | if (!this.isStarting) { 69 | this.stop(); 70 | return; 71 | } 72 | context.nodeEvent.emit('postConnect', this.id, this.connectCmdObj); 73 | 74 | if (method === 'GET') { 75 | this.playStreamPath = streamPath; 76 | this.playArgs = urlInfo.query; 77 | 78 | this.onPlay(); 79 | } else { 80 | this.stop(); 81 | } 82 | } 83 | 84 | stop() { 85 | if (this.isStarting) { 86 | this.isStarting = false; 87 | let publisherId = context.publishers.get(this.playStreamPath); 88 | if (publisherId != null) { 89 | context.sessions.get(publisherId).players.delete(this.id); 90 | context.nodeEvent.emit('donePlay', this.id, this.playStreamPath, this.playArgs); 91 | } 92 | Logger.log(`[${this.TAG} play] Close stream. id=${this.id} streamPath=${this.playStreamPath}`); 93 | Logger.log(`[${this.TAG} disconnect] id=${this.id}`); 94 | context.nodeEvent.emit('doneConnect', this.id, this.connectCmdObj); 95 | this.res.end(); 96 | context.idlePlayers.delete(this.id); 97 | context.sessions.delete(this.id); 98 | } 99 | } 100 | 101 | onReqClose() { 102 | this.stop(); 103 | } 104 | 105 | onReqError(e) { 106 | this.stop(); 107 | } 108 | 109 | reject() { 110 | Logger.log(`[${this.TAG} reject] id=${this.id}`); 111 | this.stop(); 112 | } 113 | 114 | onPlay() { 115 | context.nodeEvent.emit('prePlay', this.id, this.playStreamPath, this.playArgs); 116 | if (!this.isStarting) { 117 | return; 118 | } 119 | if (this.config.auth !== undefined && this.config.auth.play) { 120 | let results = NodeCoreUtils.verifyAuth(this.playArgs.sign, this.playStreamPath, this.config.auth.secret); 121 | if (!results) { 122 | Logger.log(`[${this.TAG} play] Unauthorized. id=${this.id} streamPath=${this.playStreamPath} sign=${this.playArgs.sign}`); 123 | this.res.statusCode = 403; 124 | this.res.end(); 125 | return; 126 | } 127 | } 128 | 129 | if (!context.publishers.has(this.playStreamPath)) { 130 | Logger.log(`[${this.TAG} play] Stream not found. id=${this.id} streamPath=${this.playStreamPath} `); 131 | context.idlePlayers.add(this.id); 132 | this.isIdling = true; 133 | return; 134 | } 135 | 136 | this.onStartPlay(); 137 | } 138 | 139 | onStartPlay() { 140 | let publisherId = context.publishers.get(this.playStreamPath); 141 | let publisher = context.sessions.get(publisherId); 142 | let players = publisher.players; 143 | players.add(this.id); 144 | 145 | //send FLV header 146 | let FLVHeader = Buffer.from([0x46, 0x4c, 0x56, 0x01, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00]); 147 | if (publisher.isFirstAudioReceived) { 148 | FLVHeader[4] |= 0b00000100; 149 | } 150 | 151 | if (publisher.isFirstVideoReceived) { 152 | FLVHeader[4] |= 0b00000001; 153 | } 154 | this.res.write(FLVHeader); 155 | 156 | //send Metadata 157 | if (publisher.metaData != null) { 158 | let packet = FlvPacket.create(publisher.metaData, 18); 159 | let tag = NodeFlvSession.createFlvTag(packet); 160 | this.res.write(tag); 161 | } 162 | 163 | //send aacSequenceHeader 164 | if (publisher.audioCodec == 10) { 165 | let packet = FlvPacket.create(publisher.aacSequenceHeader, 8); 166 | let tag = NodeFlvSession.createFlvTag(packet); 167 | this.res.write(tag); 168 | } 169 | 170 | //send avcSequenceHeader 171 | if (publisher.videoCodec == 7 || publisher.videoCodec == 12) { 172 | let packet = FlvPacket.create(publisher.avcSequenceHeader, 9); 173 | let tag = NodeFlvSession.createFlvTag(packet); 174 | this.res.write(tag); 175 | } 176 | 177 | //send gop cache 178 | if (publisher.flvGopCacheQueue != null) { 179 | for (let tag of publisher.flvGopCacheQueue) { 180 | this.res.write(tag); 181 | } 182 | } 183 | 184 | this.isIdling = false; 185 | this.isPlaying = true; 186 | Logger.log(`[${this.TAG} play] Join stream. id=${this.id} streamPath=${this.playStreamPath} `); 187 | context.nodeEvent.emit('postPlay', this.id, this.playStreamPath, this.playArgs); 188 | } 189 | 190 | static createFlvTag(packet) { 191 | let PreviousTagSize = 11 + packet.header.length; 192 | let tagBuffer = Buffer.alloc(PreviousTagSize + 4); 193 | tagBuffer[0] = packet.header.type; 194 | tagBuffer.writeUIntBE(packet.header.length, 1, 3); 195 | tagBuffer[4] = (packet.header.timestamp >> 16) & 0xff; 196 | tagBuffer[5] = (packet.header.timestamp >> 8) & 0xff; 197 | tagBuffer[6] = packet.header.timestamp & 0xff; 198 | tagBuffer[7] = (packet.header.timestamp >> 24) & 0xff; 199 | tagBuffer.writeUIntBE(0, 8, 3); 200 | tagBuffer.writeUInt32BE(PreviousTagSize, PreviousTagSize); 201 | packet.payload.copy(tagBuffer, 11, 0, packet.header.length); 202 | return tagBuffer; 203 | } 204 | } 205 | 206 | module.exports = NodeFlvSession; 207 | -------------------------------------------------------------------------------- /misc/utils/helpers.js: -------------------------------------------------------------------------------- 1 | const spawn = require('child_process').spawn; 2 | 3 | const axios = require('axios'); 4 | 5 | const fs = require('fs'); 6 | 7 | const Logger = require('../../src/node_core_logger'); 8 | 9 | const config = require('../config'); 10 | const cmd = config.ffmpeg_path; 11 | 12 | const express = require('express'); 13 | 14 | const router = context => { 15 | const router = express.Router(); 16 | 17 | router.use( 18 | express.urlencoded({ 19 | extended: true 20 | }) 21 | ); 22 | router.use(express.json()); 23 | 24 | router.post('/stop', (req, res) => { 25 | const { stream } = req.body; 26 | const path = '/live/' + stream; 27 | 28 | const id = context.publishers.get(path); 29 | if (!id) { 30 | return res.end(); 31 | } 32 | 33 | const session = context.sessions.get(id); 34 | if (!session) { 35 | return res.end(); 36 | } 37 | 38 | // Stop thumbnail generation cron 39 | if(session.task) session.task.stop(); 40 | 41 | session.reject(); 42 | }); 43 | 44 | return router; 45 | }; 46 | 47 | 48 | const auth = (data, callback) => { 49 | if(data.config.misc.ignore_auth){ 50 | callback(); 51 | return; 52 | } 53 | 54 | if(!data || !data.publishStreamPath || data.publishStreamPath.indexOf('/live/') !== 0){ 55 | data.sendStatusMessage(data.publishStreamId, 'error', 'NetStream.publish.Unauthorized', 'Authorization required.'); 56 | return; 57 | } 58 | 59 | axios.post( 60 | `${data.config.misc.api_endpoint}/live/publish`, 61 | `name=${data.publishArgs.token}&streamServer=${data.config.hostServer}&tcUrl=${data.publishStreamPath}`, { 62 | maxRedirects: 0, 63 | validateStatus: (status) => { 64 | // Bypass redirect 65 | return status == 304 || (status >= 200 && status < 300); 66 | }, 67 | headers: { 68 | Authorization: `Bearer ${data.config.misc.api_secret}`, 69 | 'Content-Type': 'application/x-www-form-urlencoded' 70 | } 71 | }) 72 | .then(() => { 73 | Logger.log(`[rtmp publish] Authorized. id=${data.id} streamPath=${data.publishStreamPath} streamId=${data.publishStreamId} token=${data.publishArgs.token} `); 74 | callback(); 75 | }).catch(error => { 76 | console.error(error); 77 | Logger.log(`[rtmp publish] Unauthorized. id=${data.id} streamPath=${data.publishStreamPath} streamId=${data.publishStreamId} token=${data.publishArgs.token} `); 78 | data.sendStatusMessage(data.publishStreamId, 'error', 'NetStream.publish.Unauthorized', 'Authorization required.'); 79 | }); 80 | }; 81 | 82 | 83 | const getStreamConfig = (name) => { 84 | return new Promise((resolve, reject) => { 85 | if(process.env.NODE_ENV === 'development'){ 86 | //resolve({archive: true}); 87 | //return; 88 | } 89 | axios.get(`${config.endpoint}/streamConfig/${name}`, { 90 | headers: { 91 | Authorization: `Bearer ${config.api_secret}` 92 | } 93 | }) 94 | .then(response => { 95 | Logger.log('Response from getStreamConfig', response); 96 | resolve(response && response.data); 97 | }).catch(error => { 98 | Logger.error(error); 99 | reject(error); 100 | }); 101 | }); 102 | }; 103 | 104 | 105 | const generateStreamThumbnail = (streamPath) => { 106 | const args = [ 107 | '-err_detect', 'ignore_err', 108 | '-ignore_unknown', 109 | '-stats', 110 | '-i', `./media${streamPath}/index.m3u8`, 111 | '-fflags', 'nobuffer+genpts+igndts', 112 | '-threads','1', 113 | '-frames:v', '1', // frames 114 | '-q:v', '25', // image quality 115 | '-an', // no audio 116 | '-y', // overwrite file 117 | `./media${streamPath}/thumbnail.jpg`, 118 | ]; 119 | 120 | Logger.log('[Thumbnail generation] screenshot', args) 121 | let inst = spawn(cmd, args, { 122 | }); 123 | inst.stdout.on('data', function(data) { 124 | //console.log('stdout: ' + data); 125 | }); 126 | inst.stderr.on('data', function(data) { 127 | //console.log('stderr: ' + data); 128 | }); 129 | 130 | inst.unref(); 131 | }; 132 | 133 | const removeStreamThumbnail = (streamPath) => { 134 | let path = `./media${streamPath}/thumbnail.jpg`; 135 | fs.unlink(path, () => { 136 | // noop 137 | }) 138 | }; 139 | 140 | const parseProgressLine = (line) => { 141 | var progress = {}; 142 | 143 | // Remove all spaces after = and trim 144 | line = line.replace(/=\s+/g, '=').trim(); 145 | var progressParts = line.split(' '); 146 | 147 | // Split every progress part by "=" to get key and value 148 | for (var i = 0; i < progressParts.length; i++) { 149 | var progressSplit = progressParts[i].split('=', 2); 150 | var key = progressSplit[0]; 151 | var value = progressSplit[1]; 152 | 153 | // This is not a progress line 154 | if (typeof value === 'undefined') 155 | return null; 156 | 157 | progress[key] = value; 158 | } 159 | 160 | return progress; 161 | }; 162 | const extractProgress = (command, stderrLine) => { 163 | var progress = parseProgressLine(stderrLine); 164 | 165 | if (progress) { 166 | // build progress report object 167 | var ret = { 168 | frames: parseInt(progress.frame, 10), 169 | currentFps: parseInt(progress.fps, 10), 170 | currentKbps: progress.bitrate ? parseFloat(progress.bitrate.replace('kbits/s', '')) : 0, 171 | targetSize: parseInt(progress.size || progress.Lsize, 10), 172 | timemark: progress.time 173 | }; 174 | command.emit('progress', ret); 175 | } 176 | }; 177 | 178 | const ABRTemplate = (name, transcodeEnabled = false) => { 179 | let line = `#EXTM3U\n#EXT-X-VERSION:4\n`; 180 | line += `#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="src",NAME="src",DEFAULT=YES,AUTOSELECT=YES,LANGUAGE="en"\n`; 181 | if (transcodeEnabled) { 182 | line += `#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=800000,RESOLUTION=640x360,VIDEO="low"\n./../../live/${name}/index_low.m3u8\n`; 183 | line += `#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1400000,RESOLUTION=842x480,VIDEO="medium"\n./../../live/${name}/index_medium.m3u8\n`; 184 | line += `#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2800000,RESOLUTION=1280x720,VIDEO="high"\n./../../live/${name}/index_high.m3u8\n`; 185 | } 186 | line += `#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=5000000,RESOLUTION=1920x1080,VIDEO="src"\n./../../live/${name}/index.m3u8`; 187 | return line; 188 | }; 189 | 190 | const makeABRPlaylist = (ouPath, name, transcodeEnabled) => { 191 | return new Promise((resolve, reject) => { 192 | const playlist = `${ouPath}/abr.m3u8`; 193 | fs.open(playlist, 'w', (err, fd) => { 194 | if (err) { 195 | reject(err.message); 196 | } else { 197 | fs.writeFile(fd, ABRTemplate(name, transcodeEnabled), errWrite => { 198 | if (errWrite) { 199 | reject(errWrite.message); 200 | return; 201 | } else { 202 | fs.close(fd, () => { 203 | resolve(); 204 | }); 205 | } 206 | }); 207 | } 208 | }); 209 | }); 210 | }; 211 | 212 | module.exports = { 213 | router, 214 | auth, 215 | getStreamConfig, 216 | generateStreamThumbnail, 217 | removeStreamThumbnail, 218 | extractProgress, 219 | makeABRPlaylist 220 | }; -------------------------------------------------------------------------------- /src/node_trans_server.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 18/3/9. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const Logger = require('./node_core_logger'); 7 | 8 | const NodeTransSession = require('./node_trans_session'); 9 | const context = require('./node_core_ctx'); 10 | const { getFFmpegVersion, getFFmpegUrl } = require('./node_core_utils'); 11 | const fs = require('fs'); 12 | const _ = require('lodash'); 13 | const mkdirp = require('mkdirp'); 14 | 15 | const makeABRPlaylist = require('../misc/utils/helpers').makeABRPlaylist; 16 | const getStreamConfig = require('../misc/utils/helpers').getStreamConfig; 17 | 18 | const transcodeTasks = require('../misc/utils/transcode').tasks; 19 | 20 | class NodeTransServer { 21 | constructor(config) { 22 | this.config = config; 23 | if(context.transSessions !== 'object') context.transSessions = new Map(); 24 | } 25 | 26 | async run() { 27 | try { 28 | mkdirp.sync(this.config.http.mediaroot); 29 | fs.accessSync(this.config.http.mediaroot, fs.constants.W_OK); 30 | } catch (error) { 31 | Logger.error(`Node Media Trans Server startup failed. MediaRoot:${this.config.http.mediaroot} cannot be written.`); 32 | return; 33 | } 34 | 35 | try { 36 | fs.accessSync(this.config.trans.ffmpeg, fs.constants.X_OK); 37 | } catch (error) { 38 | Logger.error(`Node Media Trans Server startup failed. ffmpeg:${this.config.trans.ffmpeg} cannot be executed.`); 39 | return; 40 | } 41 | 42 | let version = await getFFmpegVersion(this.config.trans.ffmpeg); 43 | if (version === '' || parseInt(version.split('.')[0]) < 4) { 44 | Logger.error('Node Media Trans Server startup failed. ffmpeg requires version 4.0.0 above'); 45 | Logger.error('Download the latest ffmpeg static program:', getFFmpegUrl()); 46 | return; 47 | } 48 | 49 | let i = this.config.trans.tasks && this.config.trans.tasks.length 50 | ? this.config.trans.tasks.length : 0; 51 | let apps = ''; 52 | while (i--) { 53 | apps += this.config.trans.tasks[i].app; 54 | apps += ' '; 55 | } 56 | context.nodeEvent.on('transAdd', this.onTransAdd.bind(this)); 57 | context.nodeEvent.on('transDel', this.onTransDel.bind(this)); 58 | context.nodeEvent.on('postPublish', this.onPostPublish.bind(this)); 59 | context.nodeEvent.on('donePublish', this.onDonePublish.bind(this)); 60 | Logger.log(`Node Media Trans Server started for apps: [ ${apps}] , MediaRoot: ${this.config.http.mediaroot}, ffmpeg version: ${version}`); 61 | } 62 | 63 | stop() { 64 | context.transSessions.forEach((session, id) => { 65 | session.end(); 66 | context.transSessions.delete(id); 67 | }); 68 | } 69 | 70 | onTransAdd(id, streamPath, taskName) { 71 | let regRes = /\/(.*)\/(.*)/gi.exec(streamPath); 72 | let [app, name] = _.slice(regRes, 1); 73 | let i = transcodeTasks && transcodeTasks.length 74 | ? transcodeTasks.length : 0; 75 | 76 | // Create ABR (adaptive bitrate) playlist 77 | let ouPath = `${this.config.http.mediaroot}/${app}/${name}`; 78 | makeABRPlaylist(ouPath, name, true); 79 | // Start transcoding sessions 80 | while (i--) { 81 | let conf = { ...transcodeTasks[i] }; 82 | conf.ffmpeg = this.config.trans.ffmpeg; 83 | conf.analyzeDuration = this.config.trans.analyzeDuration; 84 | conf.probeSize = this.config.trans.probeSize; 85 | conf.mediaroot = this.config.http.mediaroot; 86 | conf.rtmpPort = this.config.rtmp.port; 87 | conf.streamPath = streamPath; 88 | conf.streamApp = app; 89 | conf.streamName = name; 90 | if (app === conf.app && conf.name === taskName) { 91 | if(conf.rec && !conf.name){ 92 | conf.name = 'archive'; 93 | } 94 | let taskId = `${app}_${conf.name || 'index'}_${id}`; 95 | let session = new NodeTransSession(conf); 96 | context.transSessions.set(taskId, session); 97 | session.on('progress', progress => { 98 | let data = { 99 | frames: progress.frames, 100 | fps: progress.currentFps, 101 | bitRate: progress.currentKbps, 102 | time: progress.timemark, 103 | }; 104 | console.log('progress', data, taskId); 105 | if (context.transSessions.get(taskId)) { 106 | context.transSessions.get(taskId).data = data; 107 | } 108 | }); 109 | session.on('end', () => { 110 | context.transSessions.delete(taskId); 111 | }); 112 | session.run(); 113 | } 114 | } 115 | } 116 | 117 | onTransDel(id, streamPath, taskName) { 118 | let regRes = /\/(.*)\/(.*)/gi.exec(streamPath); 119 | let [app, name] = _.slice(regRes, 1); 120 | let i = transcodeTasks && transcodeTasks.length 121 | ? transcodeTasks.length : 0; 122 | while (i--) { 123 | let conf = transcodeTasks[i]; 124 | if (app === conf.app && conf.name === taskName) { 125 | if(conf.rec && !conf.name){ 126 | conf.name = 'archive'; 127 | } 128 | let taskId = `${app}_${conf.name || 'index'}_${id}`; 129 | console.log('onTransDelete', taskId); 130 | let session = context.transSessions.get(taskId); 131 | if (session) { 132 | session.end(); 133 | } 134 | } 135 | } 136 | } 137 | 138 | async onPostPublish(id, streamPath, args) { 139 | let regRes = /\/(.*)\/(.*)/gi.exec(streamPath); 140 | let [app, name] = _.slice(regRes, 1); 141 | let i = this.config.trans.tasks && this.config.trans.tasks.length 142 | ? this.config.trans.tasks.length : 0; 143 | 144 | // Create ABR (adaptive bitrate) playlist 145 | let ouPath = `${this.config.http.mediaroot}/${app}/${name}`; 146 | makeABRPlaylist(ouPath, name, this.config.misc.transcode); 147 | // Start transcoding sessions 148 | while (i--) { 149 | let conf = { ...this.config.trans.tasks[i] }; 150 | conf.ffmpeg = this.config.trans.ffmpeg; 151 | conf.analyzeDuration = this.config.trans.analyzeDuration; 152 | conf.probeSize = this.config.trans.probeSize; 153 | conf.mediaroot = conf.rec ? this.config.http.recroot : this.config.http.mediaroot; 154 | conf.rtmpPort = this.config.rtmp.port; 155 | conf.streamPath = streamPath; 156 | conf.streamApp = app; 157 | conf.streamName = name; 158 | conf.args = args; 159 | 160 | // Grab streamer details (archive status, bitrate, possibly more in the future) 161 | const streamConfig = await getStreamConfig(name); 162 | 163 | // If this is a recording task, check if they have enabled archival 164 | if(conf.rec && !streamConfig.archive){ 165 | // noop 166 | Logger.log('conf.rec but archive disabled, noop'); 167 | }else if (app === conf.app) { 168 | if(conf.rec && !conf.name){ 169 | conf.name = 'archive'; 170 | } 171 | let taskId = `${app}_${conf.name || 'index'}_${id}`; 172 | let session = new NodeTransSession(conf); 173 | context.transSessions.set(taskId, session); 174 | session.on('progress', progress => { 175 | let data = { 176 | frames: progress.frames, 177 | fps: progress.currentFps, 178 | bitRate: progress.currentKbps, 179 | time: progress.timemark, 180 | }; 181 | console.log('progress', data, taskId); 182 | if (context.transSessions.get(taskId)) { 183 | context.transSessions.get(taskId).data = data; 184 | } 185 | }); 186 | session.on('end', () => { 187 | context.transSessions.delete(taskId); 188 | }); 189 | session.run(); 190 | } 191 | } 192 | } 193 | 194 | onDonePublish(id, streamPath, args) { 195 | let regRes = /\/(.*)\/(.*)/gi.exec(streamPath); 196 | let [app, name] = _.slice(regRes, 1); 197 | let i = this.config.trans.tasks && this.config.trans.tasks.length 198 | ? this.config.trans.tasks.length : 0; 199 | while (i--) { 200 | let conf = this.config.trans.tasks[i]; 201 | if (app === conf.app) { 202 | if(conf.rec && !conf.name){ 203 | conf.name = 'archive'; 204 | } 205 | let taskId = `${app}_${conf.name || 'index'}_${id}`; 206 | console.log('onDonePublish', taskId); 207 | let session = context.transSessions.get(taskId); 208 | if (session) { 209 | session.end(); 210 | } 211 | } 212 | } 213 | } 214 | } 215 | 216 | module.exports = NodeTransServer; 217 | -------------------------------------------------------------------------------- /src/node_relay_server.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 18/3/16. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | const Logger = require('./node_core_logger'); 7 | 8 | const NodeCoreUtils = require('./node_core_utils'); 9 | const NodeRelaySession = require('./node_relay_session'); 10 | const context = require('./node_core_ctx'); 11 | const { getFFmpegVersion, getFFmpegUrl } = require('./node_core_utils'); 12 | const fs = require('fs'); 13 | const querystring = require('querystring'); 14 | const _ = require('lodash'); 15 | 16 | class NodeRelayServer { 17 | constructor(config) { 18 | console.log(config) 19 | this.config = config; 20 | this.staticCycle = null; 21 | this.staticSessions = new Map(); 22 | this.dynamicSessions = new Map(); 23 | } 24 | 25 | async run() { 26 | try { 27 | fs.accessSync(this.config.relay.ffmpeg, fs.constants.X_OK); 28 | } catch (error) { 29 | Logger.error(`Node Media Relay Server startup failed. ffmpeg:${this.config.relay.ffmpeg} cannot be executed.`); 30 | return; 31 | } 32 | 33 | let version = await getFFmpegVersion(this.config.relay.ffmpeg); 34 | if (version === '' || parseInt(version.split('.')[0]) < 4) { 35 | Logger.error('Node Media Relay Server startup failed. ffmpeg requires version 4.0.0 above'); 36 | Logger.error('Download the latest ffmpeg static program:', getFFmpegUrl()); 37 | return; 38 | } 39 | context.nodeEvent.on('relayTask', this.onRelayTask.bind(this)); 40 | context.nodeEvent.on('relayPull', this.onRelayPull.bind(this)); 41 | context.nodeEvent.on('relayPush', this.onRelayPush.bind(this)); 42 | context.nodeEvent.on('relayDelete', this.onRelayDelete.bind(this)); 43 | context.nodeEvent.on('prePlay', this.onPrePlay.bind(this)); 44 | context.nodeEvent.on('donePlay', this.onDonePlay.bind(this)); 45 | context.nodeEvent.on('postPublish', this.onPostPublish.bind(this)); 46 | context.nodeEvent.on('donePublish', this.onDonePublish.bind(this)); 47 | let updateInterval = this.config.relay.update_interval ? 48 | this.config.relay.update_interval : 1000; 49 | this.staticCycle = setInterval(this.onStatic.bind(this), updateInterval); 50 | Logger.log('Node Media Relay Server started'); 51 | } 52 | 53 | onStatic() { 54 | if (!this.config.relay.tasks) { 55 | return; 56 | } 57 | let i = this.config.relay.tasks.length; 58 | while (i--) { 59 | if (this.staticSessions.has(i)) { 60 | continue; 61 | } 62 | 63 | let conf = this.config.relay.tasks[i]; 64 | let isStatic = conf.mode === 'static'; 65 | if (isStatic) { 66 | conf.name = conf.name ? conf.name : NodeCoreUtils.genRandomName(); 67 | conf.ffmpeg = this.config.relay.ffmpeg; 68 | conf.inPath = conf.edge; 69 | conf.ouPath = `rtmp://127.0.0.1:${this.config.rtmp.port}/${conf.app}/${conf.name}`; 70 | let session = new NodeRelaySession(conf); 71 | session.id = i; 72 | session.streamPath = `/${conf.app}/${conf.name}`; 73 | session.on('end', (id) => { 74 | context.sessions.delete(id); 75 | this.staticSessions.delete(id); 76 | }); 77 | this.staticSessions.set(i, session); 78 | session.run(); 79 | Logger.log('[relay static pull] start', i, conf.inPath, 'to', conf.ouPath); 80 | } 81 | } 82 | } 83 | 84 | onRelayTask(path, url) { 85 | let conf = {}; 86 | conf.ffmpeg = this.config.relay.ffmpeg; 87 | conf.app = '-'; 88 | conf.name = '-'; 89 | conf.inPath = path; 90 | conf.ouPath = url; 91 | let session = new NodeRelaySession(conf); 92 | const id = session.id; 93 | context.sessions.set(id, session); 94 | session.on('end', (id) => { 95 | context.sessions.delete(id); 96 | this.dynamicSessions.delete(id); 97 | }); 98 | this.dynamicSessions.set(id, session); 99 | session.run(); 100 | Logger.log('[relay dynamic task] start id=' + id, conf.inPath, 'to', conf.ouPath); 101 | context.nodeEvent.emit("relayTaskDone", id); 102 | } 103 | 104 | //从远端拉推到本地 105 | onRelayPull(url, app, name, rtsp_transport) { 106 | let conf = {}; 107 | conf.app = app; 108 | conf.name = name; 109 | conf.mode = 'pull'; 110 | conf.ffmpeg = this.config.relay.ffmpeg; 111 | conf.inPath = url; 112 | if (rtsp_transport){ 113 | conf.rtsp_transport = rtsp_transport 114 | } 115 | conf.ouPath = `rtmp://127.0.0.1:${this.config.rtmp.port}/${app}/${name}`; 116 | let session = new NodeRelaySession(conf); 117 | const id = session.id; 118 | context.sessions.set(id, session); 119 | session.on('end', (id) => { 120 | context.sessions.delete(id); 121 | let list = this.dynamicSessions.get(id); 122 | if (list.indexOf(session) > -1) { 123 | list.splice(list.indexOf(session), 1); 124 | if (list.length == 0) { 125 | this.dynamicSessions.delete(id); 126 | } 127 | } 128 | }); 129 | if (!this.dynamicSessions.has(id)) { 130 | this.dynamicSessions.set(id, []); 131 | } 132 | this.dynamicSessions.get(id).push(session); 133 | session.run(); 134 | Logger.log('[relay dynamic pull] start id=' + id, conf.inPath, 'to', conf.ouPath); 135 | context.nodeEvent.emit("relayPullDone", id); 136 | 137 | } 138 | 139 | //从本地拉推到远端 140 | onRelayPush(url, app, name) { 141 | let conf = {}; 142 | conf.app = app; 143 | conf.name = name; 144 | conf.mode = 'push'; 145 | conf.ffmpeg = this.config.relay.ffmpeg; 146 | conf.inPath = `rtmp://127.0.0.1:${this.config.rtmp.port}/${app}/${name}`; 147 | conf.ouPath = url; 148 | let session = new NodeRelaySession(conf); 149 | const id = session.id; 150 | context.sessions.set(id, session); 151 | session.on('end', (id) => { 152 | context.sessions.delete(id); 153 | let list = this.dynamicSessions.get(id); 154 | if (list.indexOf(session) > -1) { 155 | list.splice(list.indexOf(session), 1); 156 | if (list.length == 0) { 157 | this.dynamicSessions.delete(id); 158 | } 159 | } 160 | }); 161 | if (!this.dynamicSessions.has(id)) { 162 | this.dynamicSessions.set(id, []); 163 | } 164 | this.dynamicSessions.get(id).push(session); 165 | session.run(); 166 | Logger.log('[relay dynamic push] start id=' + id, conf.inPath, 'to', conf.ouPath); 167 | context.nodeEvent.emit("relayPushDone", id); 168 | } 169 | 170 | onRelayDelete(id) { 171 | let session = context.sessions.get(id); 172 | 173 | if (session) { 174 | session.end(); 175 | context.sessions.delete(id) 176 | Logger.log('[Relay dynamic session] end', id); 177 | } 178 | } 179 | 180 | onPrePlay(id, streamPath, args) { 181 | if (!this.config.relay.tasks) { 182 | return; 183 | } 184 | let regRes = /\/(.*)\/(.*)/gi.exec(streamPath); 185 | let [app, stream] = _.slice(regRes, 1); 186 | 187 | let conf = this.config.relay.tasks.find((config) => config.name === stream); 188 | if (conf) { 189 | let isPull = conf.mode === 'pull'; 190 | if (isPull && app === conf.app && !context.publishers.has(streamPath) && conf) { 191 | let hasApp = conf.edge.match(/rtmp:\/\/([^\/]+)\/([^\/]+)/); 192 | conf.ffmpeg = this.config.relay.ffmpeg; 193 | conf.inPath = hasApp ? `${conf.edge}/${stream}` : `${conf.edge}`; 194 | conf.ouPath = `rtmp://127.0.0.1:${this.config.rtmp.port}${streamPath}`; 195 | if (Object.keys(args).length > 0) { 196 | conf.inPath += '?'; 197 | conf.inPath += querystring.encode(args); 198 | } 199 | let session = new NodeRelaySession(conf); 200 | session.id = id; 201 | session.on('end', (id) => { 202 | let list = this.dynamicSessions.get(id); 203 | if (list.indexOf(session) > -1) { 204 | list.splice(list.indexOf(session), 1); 205 | if (list.length == 0) { 206 | this.dynamicSessions.delete(id); 207 | } 208 | } 209 | }); 210 | if (!this.dynamicSessions.has(id)) { 211 | this.dynamicSessions.set(id, []); 212 | } 213 | this.dynamicSessions.get(id).push(session); 214 | session.run(); 215 | Logger.log('[relay dynamic pull] start id=' + id, conf.inPath, 'to', conf.ouPath); 216 | } 217 | } 218 | } 219 | 220 | onDonePlay(id, streamPath, args) { 221 | let list = Array.from(this.dynamicSessions, ([name, value]) => value ).find((session)=>{return session.conf.name === streamPath.split('/')[2]}); 222 | let publisher = context.sessions.get(context.publishers.get(streamPath)); 223 | if (list && publisher.players.size == 0) { 224 | list.slice().forEach(session => session.end()); 225 | } 226 | } 227 | 228 | onPostPublish(id, streamPath, args) { 229 | if (!this.config.relay.tasks) { 230 | return; 231 | } 232 | let regRes = /\/(.*)\/(.*)/gi.exec(streamPath); 233 | let [app, stream] = _.slice(regRes, 1); 234 | let i = this.config.relay.tasks.length; 235 | while (i--) { 236 | let conf = this.config.relay.tasks[i]; 237 | let isPush = conf.mode === 'push'; 238 | if (isPush && app === conf.app) { 239 | let hasApp = conf.edge.match(/rtmp:\/\/([^\/]+)\/([^\/]+)/); 240 | conf.ffmpeg = this.config.relay.ffmpeg; 241 | conf.inPath = `rtmp://127.0.0.1:${this.config.rtmp.port}${streamPath}`; 242 | conf.ouPath = conf.appendName === false ? conf.edge : (hasApp ? `${conf.edge}/${stream}` : `${conf.edge}${streamPath}`); 243 | if (Object.keys(args).length > 0) { 244 | conf.ouPath += '?'; 245 | conf.ouPath += querystring.encode(args); 246 | } 247 | let session = new NodeRelaySession(conf); 248 | session.id = id; 249 | session.on('end', (id) => { 250 | let list = this.dynamicSessions.get(id); 251 | if (list.indexOf(session) > -1) { 252 | list.splice(list.indexOf(session), 1); 253 | if (list.length == 0) { 254 | this.dynamicSessions.delete(id); 255 | } 256 | } 257 | }); 258 | if (!this.dynamicSessions.has(id)) { 259 | this.dynamicSessions.set(id, []); 260 | } 261 | this.dynamicSessions.get(id).push(session); 262 | session.run(); 263 | Logger.log('[relay dynamic push] start id=' + id, conf.inPath, 'to', conf.ouPath); 264 | } 265 | } 266 | 267 | } 268 | 269 | onDonePublish(id, streamPath, args) { 270 | let list = this.dynamicSessions.get(id); 271 | if (list) { 272 | list.slice().forEach(session => session.end()); 273 | } 274 | 275 | for (session of this.staticSessions.values()) { 276 | if (session.streamPath === streamPath) { 277 | session.end(); 278 | } 279 | } 280 | } 281 | 282 | stop() { 283 | clearInterval(this.staticCycle); 284 | this.dynamicSessions.forEach((value, key, map) => { 285 | value.end() 286 | this.dynamicSessions.delete(key) 287 | }) 288 | this.staticSessions.forEach((value, key, map) => { 289 | value.end() 290 | this.staticSessions.delete(key) 291 | }) 292 | } 293 | } 294 | 295 | module.exports = NodeRelayServer; 296 | -------------------------------------------------------------------------------- /src/public/static/js/main.4071166f.chunk.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[0],{281:function(e,t,a){e.exports=a(572)},286:function(e,t,a){},568:function(e,t,a){},572:function(e,t,a){"use strict";a.r(t);var n=a(1),i=a.n(n),r=a(10),o=a.n(r),s=(a(286),a(573),a(65)),l=(a(138),a(11)),c=a(9),d=a(8),p=a(22),u=a(15),m=a(23),h=(a(290),a(77)),f=a(108),y=a(74),g=a(41),v=(a(180),a(66)),x=(a(182),a(33)),b=(a(174),a(58)),E=a(76);function O(e){if(0===e)return"0 Byte";e=Number(e);var t=parseInt(Math.floor(Math.log(e)/Math.log(1024)));return Math.round(e/Math.pow(1024,t),2)+" "+["Bytes","KB","MB","GB","TB"][t]}function k(e){if(0===e)return 0;var t=8*Number(e);return Math.round(t/Math.pow(1024,2),2)}function w(e){e=Number(e);var t=Math.floor(e/86400),a=Math.floor(e%86400/3600),n=Math.floor(e%3600/60),i=Math.floor(e%60);return(t>0?t+(1===t?" day, ":" days, "):"")+(a>0?a+(1===a?" hour, ":" hours, "):"")+(n>0?n+(1===n?" minute, ":" minutes, "):"")+(i>0?i+(1===i?" second":" seconds"):"")}function j(e){e=Number(e);var t=Math.floor(e/86400),a=Math.floor(e%86400/3600),n=Math.floor(e%3600/60);return(t>0?t+"d,":"")+(a>0?a+"h,":"")+(n>0?n+"m,":"")+(Math.floor(e%60)+"s")}var I=a(107),M=a.n(I),A=a(28),S=a.n(A);a(365),a(385),a(391),a(402);function B(e){return{title:{text:e},tooltip:{trigger:"axis"},grid:{left:"2%",right:"4%",bottom:"2%",containLabel:!0},xAxis:[{type:"category",boundaryGap:!1,data:[]}],yAxis:[{type:"value",max:100}],series:[{name:e,type:"line",areaStyle:{normal:{}},data:[]}]}}var P=function(e){function t(){var e,a;Object(c.a)(this,t);for(var n=arguments.length,i=new Array(n),r=0;r30&&(i.xAxis[0].data.shift(),i.series[0].data.shift(),r.xAxis[0].data.shift(),r.series[0].data.shift(),o.xAxis[0].data.shift(),o.series[0].data.shift(),o.series[1].data.shift(),o.series[2].data.shift(),s.xAxis[0].data.shift(),s.xAxis[1].data.shift(),s.series[0].data.shift(),s.series[1].data.shift()),i.uptime=t,i.xAxis[0].data.push(n),i.series[0].data.push(e.cpu.load),r.uptime=t,r.xAxis[0].data.push(n),r.series[0].data.push((100-100*e.mem.free/e.mem.total).toFixed(2)),o.uptime=t,o.title.text="Connections "+(e.clients.rtmp+e.clients.http+e.clients.ws),o.xAxis[0].data.push(n),o.series[0].data.push(e.clients.rtmp),o.series[1].data.push(e.clients.http),o.series[2].data.push(e.clients.ws),s.uptime=t,s.xAxis[0].data.push(n),s.xAxis[1].data.push(n),s.series[0].data.push(k((e.net.inbytes-a.lastInBytes)/2)),s.series[1].data.push(k((e.net.outbytes-a.lastOtBytes)/2)),a.lastInBytes=e.net.inbytes,a.lastOtBytes=e.net.outbytes,a.setState({cpuOption:i,memOption:r,conOption:o,netOption:s})}).catch(function(e){})},a}return Object(m.a)(t,e),Object(d.a)(t,[{key:"componentDidMount",value:function(){this.fetch(),this.timer=setInterval(this.fetch,2e3)}},{key:"componentWillUnmount",value:function(){clearInterval(this.timer)}},{key:"render",value:function(){return i.a.createElement(v.a,{style:{margin:"0 -12px"}},i.a.createElement(x.a,{span:12,style:{padding:"0 12px"}},i.a.createElement(b.a,null,i.a.createElement(M.a,{echarts:S.a,ref:"echarts_react",option:this.state.conOption,style:{height:"348px",width:"100%"}}))),i.a.createElement(x.a,{span:12,style:{padding:"0 12px"}},i.a.createElement(b.a,null,i.a.createElement(M.a,{echarts:S.a,ref:"echarts_react",option:this.state.netOption,style:{height:"348px",width:"100%"}}))),i.a.createElement(x.a,{span:12,style:{padding:"0 12px",marginTop:"16px"}},i.a.createElement(b.a,null,i.a.createElement(M.a,{echarts:S.a,ref:"echarts_react",option:this.state.cpuOption,style:{height:"300px",width:"100%"}}))),i.a.createElement(x.a,{span:12,style:{padding:"0 12px",marginTop:"16px"}},i.a.createElement(b.a,null,i.a.createElement(M.a,{echarts:S.a,ref:"echarts_react",option:this.state.memOption,style:{height:"300px",width:"100%"}}))))}}]),t}(n.Component),C=(a(264),a(137)),N=[{dataIndex:"name",key:"name",width:200},{dataIndex:"value",key:"value"}],D=function(e){function t(){var e,a;Object(c.a)(this,t);for(var n=arguments.length,i=new Array(n),r=0;r 0) { 103 | return 'HEv2'; 104 | } 105 | if (info.sbr > 0) { 106 | return 'HE'; 107 | } 108 | return 'LC'; 109 | case 3: 110 | return 'SSR'; 111 | case 4: 112 | return 'LTP'; 113 | case 5: 114 | return 'SBR'; 115 | default: 116 | return ''; 117 | } 118 | } 119 | 120 | function readH264SpecificConfig(avcSequenceHeader) { 121 | let info = {}; 122 | let profile_idc, width, height, crop_left, crop_right, 123 | crop_top, crop_bottom, frame_mbs_only, n, cf_idc, 124 | num_ref_frames; 125 | let bitop = new Bitop(avcSequenceHeader); 126 | bitop.read(48); 127 | info.width = 0; 128 | info.height = 0; 129 | 130 | do { 131 | info.profile = bitop.read(8); 132 | info.compat = bitop.read(8); 133 | info.level = bitop.read(8); 134 | info.nalu = (bitop.read(8) & 0x03) + 1; 135 | info.nb_sps = bitop.read(8) & 0x1F; 136 | if (info.nb_sps == 0) { 137 | break; 138 | } 139 | /* nal size */ 140 | bitop.read(16); 141 | 142 | /* nal type */ 143 | if (bitop.read(8) != 0x67) { 144 | break; 145 | } 146 | /* SPS */ 147 | profile_idc = bitop.read(8); 148 | 149 | /* flags */ 150 | bitop.read(8); 151 | 152 | /* level idc */ 153 | bitop.read(8); 154 | 155 | /* SPS id */ 156 | bitop.read_golomb(); 157 | 158 | if (profile_idc == 100 || profile_idc == 110 || 159 | profile_idc == 122 || profile_idc == 244 || profile_idc == 44 || 160 | profile_idc == 83 || profile_idc == 86 || profile_idc == 118) { 161 | /* chroma format idc */ 162 | cf_idc = bitop.read_golomb(); 163 | 164 | if (cf_idc == 3) { 165 | 166 | /* separate color plane */ 167 | bitop.read(1); 168 | } 169 | 170 | /* bit depth luma - 8 */ 171 | bitop.read_golomb(); 172 | 173 | /* bit depth chroma - 8 */ 174 | bitop.read_golomb(); 175 | 176 | /* qpprime y zero transform bypass */ 177 | bitop.read(1); 178 | 179 | /* seq scaling matrix present */ 180 | if (bitop.read(1)) { 181 | 182 | for (n = 0; n < (cf_idc != 3 ? 8 : 12); n++) { 183 | 184 | /* seq scaling list present */ 185 | if (bitop.read(1)) { 186 | 187 | /* TODO: scaling_list() 188 | if (n < 6) { 189 | } else { 190 | } 191 | */ 192 | } 193 | } 194 | } 195 | } 196 | 197 | /* log2 max frame num */ 198 | bitop.read_golomb(); 199 | 200 | /* pic order cnt type */ 201 | switch (bitop.read_golomb()) { 202 | case 0: 203 | 204 | /* max pic order cnt */ 205 | bitop.read_golomb(); 206 | break; 207 | 208 | case 1: 209 | 210 | /* delta pic order alwys zero */ 211 | bitop.read(1); 212 | 213 | /* offset for non-ref pic */ 214 | bitop.read_golomb(); 215 | 216 | /* offset for top to bottom field */ 217 | bitop.read_golomb(); 218 | 219 | /* num ref frames in pic order */ 220 | num_ref_frames = bitop.read_golomb(); 221 | 222 | for (n = 0; n < num_ref_frames; n++) { 223 | 224 | /* offset for ref frame */ 225 | bitop.read_golomb(); 226 | } 227 | } 228 | 229 | /* num ref frames */ 230 | info.avc_ref_frames = bitop.read_golomb(); 231 | 232 | /* gaps in frame num allowed */ 233 | bitop.read(1); 234 | 235 | /* pic width in mbs - 1 */ 236 | width = bitop.read_golomb(); 237 | 238 | /* pic height in map units - 1 */ 239 | height = bitop.read_golomb(); 240 | 241 | /* frame mbs only flag */ 242 | frame_mbs_only = bitop.read(1); 243 | 244 | if (!frame_mbs_only) { 245 | 246 | /* mbs adaprive frame field */ 247 | bitop.read(1); 248 | } 249 | 250 | /* direct 8x8 inference flag */ 251 | bitop.read(1); 252 | 253 | /* frame cropping */ 254 | if (bitop.read(1)) { 255 | 256 | crop_left = bitop.read_golomb(); 257 | crop_right = bitop.read_golomb(); 258 | crop_top = bitop.read_golomb(); 259 | crop_bottom = bitop.read_golomb(); 260 | 261 | } else { 262 | crop_left = 0; 263 | crop_right = 0; 264 | crop_top = 0; 265 | crop_bottom = 0; 266 | } 267 | info.level = info.level / 10.0; 268 | info.width = (width + 1) * 16 - (crop_left + crop_right) * 2; 269 | info.height = (2 - frame_mbs_only) * (height + 1) * 16 - (crop_top + crop_bottom) * 2; 270 | 271 | } while (0); 272 | 273 | return info; 274 | } 275 | 276 | function HEVCParsePtl(bitop, hevc, max_sub_layers_minus1) { 277 | let general_ptl = {}; 278 | 279 | general_ptl.profile_space = bitop.read(2); 280 | general_ptl.tier_flag = bitop.read(1); 281 | general_ptl.profile_idc = bitop.read(5); 282 | general_ptl.profile_compatibility_flags = bitop.read(32); 283 | general_ptl.general_progressive_source_flag = bitop.read(1); 284 | general_ptl.general_interlaced_source_flag = bitop.read(1); 285 | general_ptl.general_non_packed_constraint_flag = bitop.read(1); 286 | general_ptl.general_frame_only_constraint_flag = bitop.read(1); 287 | bitop.read(32); 288 | bitop.read(12); 289 | general_ptl.level_idc = bitop.read(8); 290 | 291 | general_ptl.sub_layer_profile_present_flag = []; 292 | general_ptl.sub_layer_level_present_flag = []; 293 | 294 | for (let i = 0; i < max_sub_layers_minus1; i++) { 295 | general_ptl.sub_layer_profile_present_flag[i] = bitop.read(1); 296 | general_ptl.sub_layer_level_present_flag[i] = bitop.read(1); 297 | } 298 | 299 | if (max_sub_layers_minus1 > 0) { 300 | for (let i = max_sub_layers_minus1; i < 8; i++) { 301 | bitop.read(2); 302 | } 303 | } 304 | 305 | general_ptl.sub_layer_profile_space = []; 306 | general_ptl.sub_layer_tier_flag = []; 307 | general_ptl.sub_layer_profile_idc = []; 308 | general_ptl.sub_layer_profile_compatibility_flag = []; 309 | general_ptl.sub_layer_progressive_source_flag = []; 310 | general_ptl.sub_layer_interlaced_source_flag = []; 311 | general_ptl.sub_layer_non_packed_constraint_flag = []; 312 | general_ptl.sub_layer_frame_only_constraint_flag = []; 313 | general_ptl.sub_layer_level_idc = []; 314 | 315 | for (let i = 0; i < max_sub_layers_minus1; i++) { 316 | if (general_ptl.sub_layer_profile_present_flag[i]) { 317 | general_ptl.sub_layer_profile_space[i] = bitop.read(2); 318 | general_ptl.sub_layer_tier_flag[i] = bitop.read(1); 319 | general_ptl.sub_layer_profile_idc[i] = bitop.read(5); 320 | general_ptl.sub_layer_profile_compatibility_flag[i] = bitop.read(32); 321 | general_ptl.sub_layer_progressive_source_flag[i] = bitop.read(1); 322 | general_ptl.sub_layer_interlaced_source_flag[i] = bitop.read(1); 323 | general_ptl.sub_layer_non_packed_constraint_flag[i] = bitop.read(1); 324 | general_ptl.sub_layer_frame_only_constraint_flag[i] = bitop.read(1); 325 | bitop.read(32); 326 | bitop.read(12); 327 | } 328 | if (general_ptl.sub_layer_level_present_flag[i]) { 329 | general_ptl.sub_layer_level_idc[i] = bitop.read(8); 330 | } 331 | else { 332 | general_ptl.sub_layer_level_idc[i] = 1; 333 | } 334 | } 335 | return general_ptl; 336 | } 337 | 338 | function HEVCParseSPS(SPS, hevc) { 339 | let psps = {}; 340 | let NumBytesInNALunit = SPS.length; 341 | let NumBytesInRBSP = 0; 342 | let rbsp_array = []; 343 | let bitop = new Bitop(SPS); 344 | 345 | bitop.read(1);//forbidden_zero_bit 346 | bitop.read(6);//nal_unit_type 347 | bitop.read(6);//nuh_reserved_zero_6bits 348 | bitop.read(3);//nuh_temporal_id_plus1 349 | 350 | for (let i = 2; i < NumBytesInNALunit; i++) { 351 | if (i + 2 < NumBytesInNALunit && bitop.look(24) == 0x000003) { 352 | rbsp_array.push(bitop.read(8)); 353 | rbsp_array.push(bitop.read(8)); 354 | i += 2; 355 | let emulation_prevention_three_byte = bitop.read(8); /* equal to 0x03 */ 356 | } else { 357 | rbsp_array.push(bitop.read(8)); 358 | } 359 | } 360 | let rbsp = Buffer.from(rbsp_array); 361 | let rbspBitop = new Bitop(rbsp); 362 | psps.sps_video_parameter_set_id = rbspBitop.read(4); 363 | psps.sps_max_sub_layers_minus1 = rbspBitop.read(3); 364 | psps.sps_temporal_id_nesting_flag = rbspBitop.read(1); 365 | psps.profile_tier_level = HEVCParsePtl(rbspBitop, hevc, psps.sps_max_sub_layers_minus1); 366 | psps.sps_seq_parameter_set_id = rbspBitop.read_golomb(); 367 | psps.chroma_format_idc = rbspBitop.read_golomb(); 368 | if (psps.chroma_format_idc == 3) { 369 | psps.separate_colour_plane_flag = rbspBitop.read(1); 370 | } else { 371 | psps.separate_colour_plane_flag = 0; 372 | } 373 | psps.pic_width_in_luma_samples = rbspBitop.read_golomb(); 374 | psps.pic_height_in_luma_samples = rbspBitop.read_golomb(); 375 | psps.conformance_window_flag = rbspBitop.read(1); 376 | psps.conf_win_left_offset = 0; 377 | psps.conf_win_right_offset = 0; 378 | psps.conf_win_top_offset = 0; 379 | psps.conf_win_bottom_offset = 0; 380 | if (psps.conformance_window_flag) { 381 | let vert_mult = 1 + (psps.chroma_format_idc < 2); 382 | let horiz_mult = 1 + (psps.chroma_format_idc < 3); 383 | psps.conf_win_left_offset = rbspBitop.read_golomb() * horiz_mult; 384 | psps.conf_win_right_offset = rbspBitop.read_golomb() * horiz_mult; 385 | psps.conf_win_top_offset = rbspBitop.read_golomb() * vert_mult; 386 | psps.conf_win_bottom_offset = rbspBitop.read_golomb() * vert_mult; 387 | } 388 | // Logger.debug(psps); 389 | return psps; 390 | } 391 | 392 | function readHEVCSpecificConfig(hevcSequenceHeader) { 393 | let info = {}; 394 | info.width = 0; 395 | info.height = 0; 396 | info.profile = 0; 397 | info.level = 0; 398 | // let bitop = new Bitop(hevcSequenceHeader); 399 | // bitop.read(48); 400 | hevcSequenceHeader = hevcSequenceHeader.slice(5); 401 | 402 | do { 403 | let hevc = {}; 404 | if (hevcSequenceHeader.length < 23) { 405 | break; 406 | } 407 | 408 | hevc.configurationVersion = hevcSequenceHeader[0]; 409 | if (hevc.configurationVersion != 1) { 410 | break; 411 | } 412 | hevc.general_profile_space = (hevcSequenceHeader[1] >> 6) & 0x03; 413 | hevc.general_tier_flag = (hevcSequenceHeader[1] >> 5) & 0x01; 414 | hevc.general_profile_idc = hevcSequenceHeader[1] & 0x1F; 415 | hevc.general_profile_compatibility_flags = (hevcSequenceHeader[2] << 24) | (hevcSequenceHeader[3] << 16) | (hevcSequenceHeader[4] << 8) | hevcSequenceHeader[5]; 416 | hevc.general_constraint_indicator_flags = ((hevcSequenceHeader[6] << 24) | (hevcSequenceHeader[7] << 16) | (hevcSequenceHeader[8] << 8) | hevcSequenceHeader[9]); 417 | hevc.general_constraint_indicator_flags = (hevc.general_constraint_indicator_flags << 16) | (hevcSequenceHeader[10] << 8) | hevcSequenceHeader[11]; 418 | hevc.general_level_idc = hevcSequenceHeader[12]; 419 | hevc.min_spatial_segmentation_idc = ((hevcSequenceHeader[13] & 0x0F) << 8) | hevcSequenceHeader[14]; 420 | hevc.parallelismType = hevcSequenceHeader[15] & 0x03; 421 | hevc.chromaFormat = hevcSequenceHeader[16] & 0x03; 422 | hevc.bitDepthLumaMinus8 = hevcSequenceHeader[17] & 0x07; 423 | hevc.bitDepthChromaMinus8 = hevcSequenceHeader[18] & 0x07; 424 | hevc.avgFrameRate = (hevcSequenceHeader[19] << 8) | hevcSequenceHeader[20]; 425 | hevc.constantFrameRate = (hevcSequenceHeader[21] >> 6) & 0x03; 426 | hevc.numTemporalLayers = (hevcSequenceHeader[21] >> 3) & 0x07; 427 | hevc.temporalIdNested = (hevcSequenceHeader[21] >> 2) & 0x01; 428 | hevc.lengthSizeMinusOne = hevcSequenceHeader[21] & 0x03; 429 | let numOfArrays = hevcSequenceHeader[22]; 430 | let p = hevcSequenceHeader.slice(23); 431 | for (let i = 0; i < numOfArrays; i++) { 432 | if (p.length < 3) { 433 | brak; 434 | } 435 | let nalutype = p[0]; 436 | let n = (p[1]) << 8 | p[2]; 437 | // Logger.debug(nalutype, n); 438 | p = p.slice(3); 439 | for (let j = 0; j < n; j++) { 440 | if (p.length < 2) { 441 | break; 442 | } 443 | let k = (p[0] << 8) | p[1]; 444 | // Logger.debug('k', k); 445 | if (p.length < 2 + k) { 446 | break; 447 | } 448 | p = p.slice(2); 449 | if (nalutype == 33) { 450 | //SPS 451 | let sps = Buffer.alloc(k); 452 | p.copy(sps, 0, 0, k); 453 | // Logger.debug(sps, sps.length); 454 | hevc.psps = HEVCParseSPS(sps, hevc); 455 | info.profile = hevc.general_profile_idc; 456 | info.level = hevc.general_level_idc / 30.0; 457 | info.width = hevc.psps.pic_width_in_luma_samples - (hevc.psps.conf_win_left_offset + hevc.psps.conf_win_right_offset); 458 | info.height = hevc.psps.pic_height_in_luma_samples - (hevc.psps.conf_win_top_offset + hevc.psps.conf_win_bottom_offset); 459 | } 460 | p = p.slice(k); 461 | } 462 | } 463 | } while (0); 464 | 465 | return info; 466 | } 467 | 468 | function readAVCSpecificConfig(avcSequenceHeader) { 469 | let codec_id = avcSequenceHeader[0] & 0x0f; 470 | if (codec_id == 7) { 471 | return readH264SpecificConfig(avcSequenceHeader); 472 | } else if (codec_id == 12) { 473 | return readHEVCSpecificConfig(avcSequenceHeader); 474 | } 475 | } 476 | 477 | 478 | function getAVCProfileName(info) { 479 | switch (info.profile) { 480 | case 1: 481 | return 'Main'; 482 | case 2: 483 | return 'Main 10'; 484 | case 3: 485 | return 'Main Still Picture'; 486 | case 66: 487 | return 'Baseline'; 488 | case 77: 489 | return 'Main'; 490 | case 100: 491 | return 'High'; 492 | default: 493 | return ''; 494 | } 495 | } 496 | 497 | module.exports = { 498 | AUDIO_SOUND_RATE, 499 | AUDIO_CODEC_NAME, 500 | VIDEO_CODEC_NAME, 501 | readAACSpecificConfig, 502 | getAACProfileName, 503 | readAVCSpecificConfig, 504 | getAVCProfileName, 505 | }; 506 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Guac-Media-Server 2 | 3 | > Node.js rtmp server that has been modified for distribution by an unspecified number of people. 4 | 5 | This fork includes custom features made by me, as well as fixes merged from other forks on GitHub (with authorship preserved when possible). 6 | 7 | You should check the misc/config.sample.js and misc/index.js files for usage. 8 | 9 | **If my fork has helped you in any way, feel free to support me.** 10 | [![Support via PayPal](https://cdn.rawgit.com/twolfson/paypal-github-button/1.0.0/dist/button.svg)](https://www.paypal.me/datagutt/) 11 | 12 | ## New features 13 | 14 | - Publish authentication using stream keys (POST request to api_endpoint/live/publish) 15 | - Publish done callback (POST request to api_endpoint/live/publish_done) 16 | - Force termination of a stream 17 | - Option to enable transcoding to multiple video qualities/resolutions (low/medium/high) 18 | - Bitrate limit (configurable) 19 | - Generation of thumbnails 20 | - Archiving of streams (Uploading VOD to Amazon S3-compatible services) 21 | - Metadata hooks (bitrate, resolution, publisher etc.) 22 | - Support for running the server in PM2 (single-instance only) 23 | - [Create ABR (Adaptive Bitrate) playlist (abr.m3u8)](https://github.com/GuacLive/Guac-Media-Server/commit/1e8c52fc1fb7401420909048c5ef9bc4bd85bed1) 24 | - Add enabling/disabling of websocket server 25 | 26 | ## Fixes 27 | - [added a catch for an out of range buffer](https://github.com/GuacLive/Guac-Media-Server/commit/aa39f95) 28 | - [Allow multiple tasks per same stream](https://github.com/GuacLive/Guac-Media-Server/commit/2768a22) 29 | - [Restart relay when closes unexpectedly](https://github.com/GuacLive/Guac-Media-Server/commit/a33ef89) 30 | - [Added new hook for when metadata gets set](https://github.com/GuacLive/Guac-Media-Server/commit/4901722) 31 | - [Add Local Session Info Access](https://github.com/GuacLive/Guac-Media-Server/commit/8d45f2a) 32 | - [Exposes Context Events Through NodeMediaServer Class](https://github.com/GuacLive/Guac-Media-Server/commit/2a046c1) 33 | - [fix: add const before LOG_TYPES assingment](https://github.com/GuacLive/Guac-Media-Server/commit/06b367c) 34 | - [adds possibility to add multiple tasks to the ffmpeg transcoder with different names](https://github.com/GuacLive/Guac-Media-Server/commit/057ba92) 35 | - [Configurable analyzeduration and probesize](https://github.com/GuacLive/Guac-Media-Server/commit/cfad857) 36 | - [Creating empty index.m3u8 after transmuxing end](https://github.com/GuacLive/Guac-Media-Server/commit/445ad52) 37 | - [node_trans_session: Use try/catch when cleaning up files](https://github.com/GuacLive/Guac-Media-Server/commit/37887cc) 38 | - [Fix memory api typos](https://github.com/GuacLive/Guac-Media-Server/commit/98d4c08) 39 | - [Regularly check bitrate](https://github.com/GuacLive/Guac-Media-Server/commit/7908799) 40 | - [Option for en/disabling the API in the config](https://github.com/GuacLive/Guac-Media-Server/commit/21af5e4) 41 | - [Add events for video, audio, and data](https://github.com/GuacLive/Guac-Media-Server/commit/33dee95a6e2e92f03e058b2acf4c25a261174cd9) 42 | - [add more opt](https://github.com/GuacLive/Guac-Media-Server/commit/613d524) 43 | > and probably more that i forgot about 44 | 45 | ## Used by the following services 46 | 47 | - [Guac](https://guac.tv) 48 | 49 | --- 50 | 51 | 52 | # Node-Media-Server 53 | [![npm](https://img.shields.io/node/v/node-media-server.svg)](https://nodejs.org/en/) 54 | [![npm](https://img.shields.io/npm/v/node-media-server.svg)](https://npmjs.org/package/node-media-server) 55 | [![npm](https://img.shields.io/npm/dm/node-media-server.svg)](https://npmjs.org/package/node-media-server) 56 | [![npm](https://img.shields.io/npm/l/node-media-server.svg)](LICENSE) 57 | [![Join the chat at https://gitter.im/Illuspas/Node-Media-Server](https://badges.gitter.im/Illuspas/Node-Media-Server.svg)](https://gitter.im/Illuspas/Node-Media-Server?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 58 | 59 | ![logo](https://www.nodemedia.cn/uploads/site_logo.png) 60 | 61 | A Node.js implementation of RTMP/HTTP-FLV/WS-FLV/HLS/DASH Media Server 62 | [中文介绍](https://github.com/illuspas/Node-Media-Server/blob/master/README_CN.md) 63 | 64 | **If you like this project you can support me.** 65 | Buy Me A Coffee 66 | 67 | # Web Admin Panel Source 68 | [https://github.com/illuspas/Node-Media-Server-Admin](https://github.com/illuspas/Node-Media-Server-Admin) 69 | 70 | # Web Admin Panel Screenshot 71 | [http://server_ip:8000/admin](http://server_ip:8000/admin) 72 | 73 | ![admin](https://raw.githubusercontent.com/illuspas/resources/master/img/admin_panel_dashboard.png) 74 | ![preview](https://raw.githubusercontent.com/illuspas/resources/master/img/admin_panel_streams_preview.png) 75 | 76 | # Features 77 | - Cross platform support Windows/Linux/Unix 78 | - Support H.264/AAC/MP3/SPEEX/NELLYMOSER/G.711 79 | - Extension support H.265(flv_id=12)/VP8(flv_id=10)/VP9(flv_id=11)/OPUS(flv_id=13) 80 | - Support GOP cache 81 | - Support remux to LIVE-HTTP/WS-FLV, Support [NodePlayer.js](https://www.nodemedia.cn/product/nodeplayer-js) playback 82 | - Support remux to HLS/DASH/MP4 83 | - Support xycdn style authentication 84 | - Support event callback 85 | - Support https/wss 86 | - Support Server Monitor 87 | - Support Rtsp/Rtmp relay 88 | - Support api control relay 89 | - Support real-time multi-resolution transcoding 90 | 91 | # Usage 92 | 93 | ## npx 94 | ```bash 95 | npx node-media-server 96 | ``` 97 | 98 | ## install as a global program 99 | ```bash 100 | mkdir nms 101 | cd nms 102 | git clone https://github.com/GuacLive/Guac-Media-Server 103 | yarn 104 | npm start 105 | ``` 106 | 107 | ## npm version (recommended) 108 | 109 | ```bash 110 | mkdir nms 111 | cd nms 112 | npm install node-media-server 113 | vi app.js 114 | ``` 115 | 116 | ```js 117 | const NodeMediaServer = require('node-media-server'); 118 | 119 | const config = { 120 | rtmp: { 121 | port: 1935, 122 | chunk_size: 60000, 123 | gop_cache: true, 124 | ping: 30, 125 | ping_timeout: 60 126 | }, 127 | http: { 128 | port: 8000, 129 | allow_origin: '*' 130 | } 131 | }; 132 | 133 | var nms = new NodeMediaServer(config) 134 | nms.run(); 135 | ``` 136 | 137 | ```bash 138 | node app.js 139 | ``` 140 | 141 | # Publishing live streams 142 | ## From FFmpeg 143 | >If you have a video file with H.264 video and AAC audio: 144 | ```bash 145 | ffmpeg -re -i INPUT_FILE_NAME -c copy -f flv rtmp://localhost/live/STREAM_NAME 146 | ``` 147 | 148 | Or if you have a video file that is encoded in other audio/video format: 149 | ```bash 150 | ffmpeg -re -i INPUT_FILE_NAME -c:v libx264 -preset veryfast -tune zerolatency -c:a aac -ar 44100 -f flv rtmp://localhost/live/STREAM_NAME 151 | ``` 152 | 153 | ## From OBS 154 | >Settings -> Stream 155 | 156 | Stream Type : Custom Streaming Server 157 | 158 | URL : rtmp://localhost/live 159 | 160 | Stream key : STREAM_NAME 161 | 162 | # Accessing the live stream 163 | ## RTMP 164 | ``` 165 | rtmp://localhost/live/STREAM_NAME 166 | ``` 167 | 168 | ## http-flv 169 | ``` 170 | http://localhost:8000/live/STREAM_NAME.flv 171 | ``` 172 | 173 | ## websocket-flv 174 | ``` 175 | ws://localhost:8000/live/STREAM_NAME.flv 176 | ``` 177 | 178 | ## HLS 179 | ``` 180 | http://localhost:8000/live/STREAM_NAME/index.m3u8 181 | ``` 182 | 183 | ## DASH 184 | ``` 185 | http://localhost:8000/live/STREAM_NAME/index.mpd 186 | ``` 187 | 188 | ## via flv.js over http-flv 189 | 190 | ```html 191 | 192 | 193 | 205 | ``` 206 | 207 | ## via flv.js over websocket-flv 208 | 209 | ```html 210 | 211 | 212 | 224 | ``` 225 | 226 | # Logging 227 | ## Modify the logging type 228 | It is now possible to modify the logging type which determines which console outputs are shown. 229 | 230 | There are a total of 4 possible options: 231 | - 0 - Don't log anything 232 | - 1 - Log errors 233 | - 2 - Log errors and generic info 234 | - 3 - Log everything (debug) 235 | 236 | Modifying the logging type is easy - just add a new value `logType` in the config and set it to a value between 0 and 4. 237 | By default, this is set to show errors and generic info internally (setting 2). 238 | 239 | ```js 240 | const NodeMediaServer = require('node-media-server'); 241 | 242 | const config = { 243 | logType: 3, 244 | 245 | rtmp: { 246 | port: 1935, 247 | chunk_size: 60000, 248 | gop_cache: true, 249 | ping: 30, 250 | ping_timeout: 60 251 | }, 252 | http: { 253 | port: 8000, 254 | allow_origin: '*' 255 | } 256 | }; 257 | 258 | var nms = new NodeMediaServer(config) 259 | nms.run(); 260 | 261 | ``` 262 | 263 | # Authentication 264 | ## Encryption URL consists of: 265 | > rtmp://hostname:port/appname/stream?sign=expires-HashValue 266 | > http://hostname:port/appname/stream.flv?sign=expires-HashValue 267 | > ws://hostname:port/appname/stream.flv?sign=expires-HashValue 268 | 269 | 1.Publish or play address: 270 | >rtmp://192.168.0.10/live/stream 271 | 272 | 2.Config set auth->secret: 'nodemedia2017privatekey' 273 | ```js 274 | const config = { 275 | rtmp: { 276 | port: 1935, 277 | chunk_size: 60000, 278 | gop_cache: true, 279 | ping: 30, 280 | ping_timeout: 60 281 | }, 282 | http: { 283 | port: 8000, 284 | allow_origin: '*' 285 | }, 286 | auth: { 287 | play: true, 288 | publish: true, 289 | secret: 'nodemedia2017privatekey' 290 | } 291 | } 292 | ``` 293 | 3.expiration time: 2017/8/23 11:25:21 ,The calculated expiration timestamp is 294 | >1503458721 295 | 296 | 4.The combination HashValue is: 297 | >HashValue = md5("/live/stream-1503458721-nodemedia2017privatekey”) 298 | >HashValue = 80c1d1ad2e0c2ab63eebb50eed64201a 299 | 300 | 5.Final request address 301 | > rtmp://192.168.0.10/live/stream?sign=1503458721-80c1d1ad2e0c2ab63eebb50eed64201a 302 | > The 'sign' keyword can not be modified 303 | 304 | # H.265 over RTMP 305 | - Play:[NodeMediaClient-Android](#android) and [NodeMediaClient-iOS](#ios) 306 | - Commercial Pure JavaScrip live stream player: [NodePlayer.js](https://www.nodemedia.cn/product/nodeplayer-js) 307 | - OpenSource Pure JavaScrip live stream player: [pro-flv.js](https://github.com/illuspas/pro-fiv.js) 308 | 309 | # VP8 VP9 OPUS over RTMP 310 | [Extension ffmpeg](https://github.com/NodeMedia/ffmpeg) 311 | 312 | # Event callback 313 | ```js 314 | ...... 315 | nms.run(); 316 | nms.on('preConnect', (id, args) => { 317 | console.log('[NodeEvent on preConnect]', `id=${id} args=${JSON.stringify(args)}`); 318 | // let session = nms.getSession(id); 319 | // session.reject(); 320 | }); 321 | 322 | nms.on('postConnect', (id, args) => { 323 | console.log('[NodeEvent on postConnect]', `id=${id} args=${JSON.stringify(args)}`); 324 | }); 325 | 326 | nms.on('doneConnect', (id, args) => { 327 | console.log('[NodeEvent on doneConnect]', `id=${id} args=${JSON.stringify(args)}`); 328 | }); 329 | 330 | nms.on('prePublish', (id, StreamPath, args) => { 331 | console.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 332 | // let session = nms.getSession(id); 333 | // session.reject(); 334 | }); 335 | 336 | nms.on('postPublish', (id, StreamPath, args) => { 337 | console.log('[NodeEvent on postPublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 338 | }); 339 | 340 | nms.on('donePublish', (id, StreamPath, args) => { 341 | console.log('[NodeEvent on donePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 342 | }); 343 | 344 | nms.on('prePlay', (id, StreamPath, args) => { 345 | console.log('[NodeEvent on prePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 346 | // let session = nms.getSession(id); 347 | // session.reject(); 348 | }); 349 | 350 | nms.on('postPlay', (id, StreamPath, args) => { 351 | console.log('[NodeEvent on postPlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 352 | }); 353 | 354 | nms.on('donePlay', (id, StreamPath, args) => { 355 | console.log('[NodeEvent on donePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); 356 | }); 357 | ``` 358 | # Https/Wss 359 | 360 | ## Generate certificate 361 | ```bash 362 | openssl genrsa -out privatekey.pem 1024 363 | openssl req -new -key privatekey.pem -out certrequest.csr 364 | openssl x509 -req -in certrequest.csr -signkey privatekey.pem -out certificate.pem 365 | ``` 366 | 367 | ## Config https 368 | ```js 369 | const NodeMediaServer = require('node-media-server'); 370 | 371 | const config = { 372 | rtmp: { 373 | port: 1935, 374 | chunk_size: 60000, 375 | gop_cache: true, 376 | ping: 30, 377 | ping_timeout: 60 378 | }, 379 | http: { 380 | port: 8000, 381 | allow_origin: '*' 382 | }, 383 | https: { 384 | port: 8443, 385 | key:'./privatekey.pem', 386 | cert:'./certificate.pem', 387 | } 388 | }; 389 | 390 | 391 | var nms = new NodeMediaServer(config) 392 | nms.run(); 393 | ``` 394 | ## Accessing 395 | ``` 396 | https://localhost:8443/live/STREAM_NAME.flv 397 | wss://localhost:8443/live/STREAM_NAME.flv 398 | ``` 399 | >In the browser environment, Self-signed certificates need to be added with trust before they can be accessed. 400 | 401 | # API 402 | ## Protected API 403 | ``` 404 | const config = { 405 | ....... 406 | auth: { 407 | api : true, 408 | api_user: 'admin', 409 | api_pass: 'nms2018', 410 | }, 411 | 412 | ...... 413 | } 414 | ``` 415 | >Based on the basic auth,Please change your password. 416 | >The default is not turned on 417 | 418 | ## Server stats 419 | http://localhost:8000/api/server 420 | 421 | ```json 422 | { 423 | "os": { 424 | "arch": "x64", 425 | "platform": "darwin", 426 | "release": "16.7.0" 427 | }, 428 | "cpu": { 429 | "num": 8, 430 | "load": 12, 431 | "model": "Intel(R) Core(TM) i7-4790 CPU @ 3.60GHz", 432 | "speed": 3592 433 | }, 434 | "mem": { 435 | "total": 8589934592, 436 | "free": 754126848 437 | }, 438 | "net": { 439 | "inbytes": 6402345, 440 | "outbytes": 6901489 441 | }, 442 | "nodejs": { 443 | "uptime": 109, 444 | "version": "v8.9.0", 445 | "mem": { 446 | "rss": 59998208, 447 | "heapTotal": 23478272, 448 | "heapUsed": 15818096, 449 | "external": 3556366 450 | } 451 | }, 452 | "clients": { 453 | "accepted": 207, 454 | "active": 204, 455 | "idle": 0, 456 | "rtmp": 203, 457 | "http": 1, 458 | "ws": 0 459 | } 460 | } 461 | ``` 462 | 463 | ## Streams stats 464 | http://localhost:8000/api/streams 465 | 466 | ```json 467 | { 468 | "live": { 469 | "s": { 470 | "publisher": { 471 | "app": "live", 472 | "stream": "s", 473 | "clientId": "U3UYQ02P", 474 | "connectCreated": "2017-12-21T02:29:13.594Z", 475 | "bytes": 190279524, 476 | "ip": "::1", 477 | "audio": { 478 | "codec": "AAC", 479 | "profile": "LC", 480 | "samplerate": 48000, 481 | "channels": 6 482 | }, 483 | "video": { 484 | "codec": "H264", 485 | "width": 1920, 486 | "height": 1080, 487 | "profile": "Main", 488 | "level": 4.1, 489 | "fps": 24 490 | } 491 | }, 492 | "subscribers": [ 493 | { 494 | "app": "live", 495 | "stream": "s", 496 | "clientId": "H227P4IR", 497 | "connectCreated": "2017-12-21T02:31:35.278Z", 498 | "bytes": 18591846, 499 | "ip": "::ffff:127.0.0.1", 500 | "protocol": "http" 501 | }, 502 | { 503 | "app": "live", 504 | "stream": "s", 505 | "clientId": "ZNULPE9K", 506 | "connectCreated": "2017-12-21T02:31:45.394Z", 507 | "bytes": 8744478, 508 | "ip": "::ffff:127.0.0.1", 509 | "protocol": "ws" 510 | }, 511 | { 512 | "app": "live", 513 | "stream": "s", 514 | "clientId": "C5G8NJ30", 515 | "connectCreated": "2017-12-21T02:31:51.736Z", 516 | "bytes": 2046073, 517 | "ip": "::ffff:192.168.0.91", 518 | "protocol": "rtmp" 519 | } 520 | ] 521 | }, 522 | "stream": { 523 | "publisher": null, 524 | "subscribers": [ 525 | { 526 | "app": "live", 527 | "stream": "stream", 528 | "clientId": "KBH4PCWB", 529 | "connectCreated": "2017-12-21T02:31:30.245Z", 530 | "bytes": 0, 531 | "ip": "::ffff:127.0.0.1", 532 | "protocol": "http" 533 | } 534 | ] 535 | } 536 | } 537 | } 538 | ``` 539 | 540 | # Remux to HLS/DASH live stream 541 | ```js 542 | const NodeMediaServer = require('node-media-server'); 543 | 544 | const config = { 545 | rtmp: { 546 | port: 1935, 547 | chunk_size: 60000, 548 | gop_cache: true, 549 | ping: 30, 550 | ping_timeout: 60 551 | }, 552 | http: { 553 | port: 8000, 554 | mediaroot: './media', 555 | allow_origin: '*' 556 | }, 557 | trans: { 558 | ffmpeg: '/usr/local/bin/ffmpeg', 559 | tasks: [ 560 | { 561 | app: 'live', 562 | hls: true, 563 | hlsFlags: '[hls_time=2:hls_list_size=3:hls_flags=delete_segments]', 564 | hlsKeep: true, // to prevent file delete after end the stream 565 | dash: true, 566 | dashFlags: '[f=dash:window_size=3:extra_window_size=5]' 567 | } 568 | ] 569 | } 570 | }; 571 | 572 | var nms = new NodeMediaServer(config) 573 | nms.run(); 574 | ``` 575 | 576 | # Remux to RTMP/HLS/DASH live stream with audio transcode 577 | ```js 578 | const NodeMediaServer = require('node-media-server'); 579 | 580 | const config = { 581 | rtmp: { 582 | port: 1935, 583 | chunk_size: 60000, 584 | gop_cache: true, 585 | ping: 30, 586 | ping_timeout: 60 587 | }, 588 | http: { 589 | port: 8000, 590 | mediaroot: './media', 591 | allow_origin: '*' 592 | }, 593 | trans: { 594 | ffmpeg: '/usr/local/bin/ffmpeg', 595 | tasks: [ 596 | { 597 | app: 'live', 598 | vc: "copy", 599 | vcParam: [], 600 | ac: "aac", 601 | acParam: ['-ab', '64k', '-ac', '1', '-ar', '44100'], 602 | rtmp:true, 603 | rtmpApp:'live2', 604 | hls: true, 605 | hlsFlags: '[hls_time=2:hls_list_size=3:hls_flags=delete_segments]', 606 | dash: true, 607 | dashFlags: '[f=dash:window_size=3:extra_window_size=5]' 608 | } 609 | ] 610 | } 611 | }; 612 | 613 | var nms = new NodeMediaServer(config) 614 | nms.run(); 615 | ``` 616 | >Remux to RTMP cannot use the same app name 617 | 618 | 619 | # Record to MP4 620 | ```JS 621 | const NodeMediaServer = require('node-media-server'); 622 | 623 | const config = { 624 | rtmp: { 625 | port: 1935, 626 | chunk_size: 60000, 627 | gop_cache: true, 628 | ping: 30, 629 | ping_timeout: 60 630 | }, 631 | http: { 632 | port: 8000, 633 | mediaroot: './media', 634 | allow_origin: '*' 635 | }, 636 | trans: { 637 | ffmpeg: '/usr/local/bin/ffmpeg', 638 | tasks: [ 639 | { 640 | app: 'live', 641 | mp4: true, 642 | mp4Flags: '[movflags=frag_keyframe+empty_moov]', 643 | } 644 | ] 645 | } 646 | }; 647 | 648 | var nms = new NodeMediaServer(config) 649 | nms.run(); 650 | ``` 651 | 652 | # Rtsp/Rtmp Relay 653 | NodeMediaServer implement RTSP and RTMP relay with ffmpeg. 654 | 655 | ## Static pull 656 | The static pull mode is executed at service startup and reconnect after failure. 657 | It could be a live stream or a file. In theory, it is not limited to RTSP or RTMP protocol. 658 | 659 | ``` 660 | relay: { 661 | ffmpeg: '/usr/local/bin/ffmpeg', 662 | tasks: [ 663 | { 664 | app: 'cctv', 665 | mode: 'static', 666 | edge: 'rtsp://admin:admin888@192.168.0.149:554/ISAPI/streaming/channels/101', 667 | name: '0_149_101', 668 | rtsp_transport : 'tcp' //['udp', 'tcp', 'udp_multicast', 'http'] 669 | }, { 670 | app: 'iptv', 671 | mode: 'static', 672 | edge: 'rtmp://live.hkstv.hk.lxdns.com/live/hks', 673 | name: 'hks' 674 | }, { 675 | app: 'mv', 676 | mode: 'static', 677 | edge: '/Volumes/ExtData/Movies/Dancing.Queen-SD.mp4', 678 | name: 'dq' 679 | } 680 | ] 681 | } 682 | ``` 683 | 684 | ## Dynamic pull 685 | When the local server receives a play request. 686 | If the stream does not exist, pull the stream from the configured edge server to local. 687 | When the stream is not played by the client, it automatically disconnects. 688 | 689 | ``` 690 | relay: { 691 | ffmpeg: '/usr/local/bin/ffmpeg', 692 | tasks: [ 693 | { 694 | app: 'live', 695 | mode: 'pull', 696 | edge: 'rtmp://192.168.0.20', 697 | } 698 | ] 699 | } 700 | ``` 701 | 702 | ## Dynamic push 703 | When the local server receives a publish request. 704 | Automatically push the stream to the edge server. 705 | 706 | ``` 707 | relay: { 708 | ffmpeg: '/usr/local/bin/ffmpeg', 709 | tasks: [ 710 | { 711 | app: 'live', 712 | mode: 'push', 713 | edge: 'rtmp://192.168.0.10', 714 | } 715 | ] 716 | } 717 | ``` 718 | 719 | # Fission 720 | Real-time transcoding multi-resolution output 721 | ![fission](https://raw.githubusercontent.com/illuspas/resources/master/img/admin_panel_fission.png) 722 | ``` 723 | fission: { 724 | ffmpeg: '/usr/local/bin/ffmpeg', 725 | tasks: [ 726 | { 727 | rule: "game/*", 728 | model: [ 729 | { 730 | ab: "128k", 731 | vb: "1500k", 732 | vs: "1280x720", 733 | vf: "30", 734 | }, 735 | { 736 | ab: "96k", 737 | vb: "1000k", 738 | vs: "854x480", 739 | vf: "24", 740 | }, 741 | { 742 | ab: "96k", 743 | vb: "600k", 744 | vs: "640x360", 745 | vf: "20", 746 | }, 747 | ] 748 | }, 749 | { 750 | rule: "show/*", 751 | model: [ 752 | { 753 | ab: "128k", 754 | vb: "1500k", 755 | vs: "720x1280", 756 | vf: "30", 757 | }, 758 | { 759 | ab: "96k", 760 | vb: "1000k", 761 | vs: "480x854", 762 | vf: "24", 763 | }, 764 | { 765 | ab: "64k", 766 | vb: "600k", 767 | vs: "360x640", 768 | vf: "20", 769 | }, 770 | ] 771 | }, 772 | ] 773 | } 774 | ``` 775 | 776 | # Publisher and Player App/SDK 777 | 778 | ## Android Livestream App 779 | https://play.google.com/store/apps/details?id=cn.nodemedia.qlive 780 | http://www.nodemedia.cn/uploads/qlive-release.apk 781 | 782 | ## Android SDK 783 | https://github.com/NodeMedia/NodeMediaClient-Android 784 | 785 | ## iOS SDK 786 | https://github.com/NodeMedia/NodeMediaClient-iOS 787 | 788 | ## React-Native SDK 789 | https://github.com/NodeMedia/react-native-nodemediaclient 790 | 791 | ## NodePlayer.js HTML5 live player 792 | * Implemented with asm.js / wasm 793 | * http-flv/ws-flv 794 | * H.264/H.265 + AAC/Nellymoser/G.711 decoder 795 | * Ultra low latency (Support for iOS safari browser) 796 | 797 | http://www.nodemedia.cn/products/node-media-player 798 | 799 | ## Windows browser plugin(ActiveX/NPAPI) 800 | * H.264/H.265+AAC rtmp publisher 801 | * Camera/Desktop + Microphone capture 802 | * Nvidia/AMD/Intel Hardware acceleration Encoder/Decoder 803 | * Ultra low latency rtmp/rtsp/http live player 804 | * Only 6MB installation package 805 | 806 | http://www.nodemedia.cn/products/node-media-client/win 807 | 808 | # Thanks 809 | Sorng Sothearith, standifer1023, floatflower, Christopher Thomas, strive, jaysonF, 匿名, 李勇, 巴草根, ZQL, 陈勇至, -Y, 高山流水, 老郭, 孙建, 不说本可以, Jacky, 人走茶凉,树根, 疯狂的台灯, 枫叶, lzq, 番茄, smicroz , kasra.shahram, 熊科辉, Ken Lee , Erik Herz, Javier Gomez, trustfarm, leeoxiang, Aaron Turner, Anonymous 810 | 811 | Thank you for your support. 812 | -------------------------------------------------------------------------------- /src/node_rtmp_client.js: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mingliang Chen on 18/6/21. 3 | // illuspas[a]gmail.com 4 | // Copyright (c) 2018 Nodemedia. All rights reserved. 5 | // 6 | 7 | const EventEmitter = require('events'); 8 | const Logger = require('./node_core_logger'); 9 | const Crypto = require('crypto'); 10 | const Url = require('url'); 11 | const Net = require('net'); 12 | const AMF = require('./node_core_amf'); 13 | 14 | const FLASHVER = 'LNX 9,0,124,2'; 15 | const RTMP_OUT_CHUNK_SIZE = 60000; 16 | const RTMP_PORT = 1935; 17 | 18 | const RTMP_HANDSHAKE_SIZE = 1536; 19 | const RTMP_HANDSHAKE_UNINIT = 0; 20 | const RTMP_HANDSHAKE_0 = 1; 21 | const RTMP_HANDSHAKE_1 = 2; 22 | const RTMP_HANDSHAKE_2 = 3; 23 | 24 | const RTMP_PARSE_INIT = 0; 25 | const RTMP_PARSE_BASIC_HEADER = 1; 26 | const RTMP_PARSE_MESSAGE_HEADER = 2; 27 | const RTMP_PARSE_EXTENDED_TIMESTAMP = 3; 28 | const RTMP_PARSE_PAYLOAD = 4; 29 | 30 | const RTMP_CHUNK_HEADER_MAX = 18; 31 | 32 | const RTMP_CHUNK_TYPE_0 = 0; // 11-bytes: timestamp(3) + length(3) + stream type(1) + stream id(4) 33 | const RTMP_CHUNK_TYPE_1 = 1; // 7-bytes: delta(3) + length(3) + stream type(1) 34 | const RTMP_CHUNK_TYPE_2 = 2; // 3-bytes: delta(3) 35 | const RTMP_CHUNK_TYPE_3 = 3; // 0-byte 36 | 37 | const RTMP_CHANNEL_PROTOCOL = 2; 38 | const RTMP_CHANNEL_INVOKE = 3; 39 | const RTMP_CHANNEL_AUDIO = 4; 40 | const RTMP_CHANNEL_VIDEO = 5; 41 | const RTMP_CHANNEL_DATA = 6; 42 | 43 | const rtmpHeaderSize = [11, 7, 3, 0]; 44 | 45 | 46 | /* Protocol Control Messages */ 47 | const RTMP_TYPE_SET_CHUNK_SIZE = 1; 48 | const RTMP_TYPE_ABORT = 2; 49 | const RTMP_TYPE_ACKNOWLEDGEMENT = 3; // bytes read report 50 | const RTMP_TYPE_WINDOW_ACKNOWLEDGEMENT_SIZE = 5; // server bandwidth 51 | const RTMP_TYPE_SET_PEER_BANDWIDTH = 6; // client bandwidth 52 | 53 | /* User Control Messages Event (4) */ 54 | const RTMP_TYPE_EVENT = 4; 55 | 56 | const RTMP_TYPE_AUDIO = 8; 57 | const RTMP_TYPE_VIDEO = 9; 58 | 59 | /* Data Message */ 60 | const RTMP_TYPE_FLEX_STREAM = 15; // AMF3 61 | const RTMP_TYPE_DATA = 18; // AMF0 62 | 63 | /* Shared Object Message */ 64 | const RTMP_TYPE_FLEX_OBJECT = 16; // AMF3 65 | const RTMP_TYPE_SHARED_OBJECT = 19; // AMF0 66 | 67 | /* Command Message */ 68 | const RTMP_TYPE_FLEX_MESSAGE = 17; // AMF3 69 | const RTMP_TYPE_INVOKE = 20; // AMF0 70 | 71 | /* Aggregate Message */ 72 | const RTMP_TYPE_METADATA = 22; 73 | 74 | const RTMP_CHUNK_SIZE = 128; 75 | const RTMP_PING_TIME = 60000; 76 | const RTMP_PING_TIMEOUT = 30000; 77 | 78 | const STREAM_BEGIN = 0x00; 79 | const STREAM_EOF = 0x01; 80 | const STREAM_DRY = 0x02; 81 | const STREAM_EMPTY = 0x1f; 82 | const STREAM_READY = 0x20; 83 | 84 | const RTMP_TRANSACTION_CONNECT = 1; 85 | const RTMP_TRANSACTION_CREATE_STREAM = 2; 86 | const RTMP_TRANSACTION_GET_STREAM_LENGTH = 3; 87 | 88 | const RtmpPacket = { 89 | create: (fmt = 0, cid = 0) => { 90 | return { 91 | header: { 92 | fmt: fmt, 93 | cid: cid, 94 | timestamp: 0, 95 | length: 0, 96 | type: 0, 97 | stream_id: 0 98 | }, 99 | clock: 0, 100 | delta: 0, 101 | payload: null, 102 | capacity: 0, 103 | bytes: 0 104 | }; 105 | } 106 | }; 107 | 108 | class NodeRtmpClient { 109 | constructor(rtmpUrl) { 110 | this.url = rtmpUrl; 111 | this.info = this.rtmpUrlParser(rtmpUrl); 112 | this.isPublish = false; 113 | this.launcher = new EventEmitter(); 114 | 115 | this.handshakePayload = Buffer.alloc(RTMP_HANDSHAKE_SIZE); 116 | this.handshakeState = RTMP_HANDSHAKE_UNINIT; 117 | this.handshakeBytes = 0; 118 | 119 | this.parserBuffer = Buffer.alloc(RTMP_CHUNK_HEADER_MAX); 120 | this.parserState = RTMP_PARSE_INIT; 121 | this.parserBytes = 0; 122 | this.parserBasicBytes = 0; 123 | this.parserPacket = null; 124 | this.inPackets = new Map(); 125 | 126 | this.inChunkSize = RTMP_CHUNK_SIZE; 127 | this.outChunkSize = RTMP_CHUNK_SIZE; 128 | 129 | this.streamId = 0; 130 | this.isSocketOpen = false; 131 | } 132 | 133 | onSocketData(data) { 134 | let bytes = data.length; 135 | let p = 0; 136 | let n = 0; 137 | while (bytes > 0) { 138 | switch (this.handshakeState) { 139 | case RTMP_HANDSHAKE_UNINIT: 140 | // read s0 141 | // Logger.debug('[rtmp client] read s0'); 142 | this.handshakeState = RTMP_HANDSHAKE_0; 143 | this.handshakeBytes = 0; 144 | bytes -= 1; 145 | p += 1; 146 | break; 147 | case RTMP_HANDSHAKE_0: 148 | // read s1 149 | n = RTMP_HANDSHAKE_SIZE - this.handshakeBytes; 150 | n = n <= bytes ? n : bytes; 151 | data.copy(this.handshakePayload, this.handshakeBytes, p, p + n); 152 | this.handshakeBytes += n; 153 | bytes -= n; 154 | p += n; 155 | if (this.handshakeBytes === RTMP_HANDSHAKE_SIZE) { 156 | // Logger.debug('[rtmp client] read s1'); 157 | this.handshakeState = RTMP_HANDSHAKE_1; 158 | this.handshakeBytes = 0; 159 | this.socket.write(this.handshakePayload);// write c2; 160 | // Logger.debug('[rtmp client] write c2'); 161 | } 162 | break; 163 | case RTMP_HANDSHAKE_1: 164 | //read s2 165 | n = RTMP_HANDSHAKE_SIZE - this.handshakeBytes; 166 | n = n <= bytes ? n : bytes; 167 | data.copy(this.handshakePayload, this.handshakeBytes, p, n); 168 | this.handshakeBytes += n; 169 | bytes -= n; 170 | p += n; 171 | if (this.handshakeBytes === RTMP_HANDSHAKE_SIZE) { 172 | // Logger.debug('[rtmp client] read s2'); 173 | this.handshakeState = RTMP_HANDSHAKE_2; 174 | this.handshakeBytes = 0; 175 | this.handshakePayload = null; 176 | 177 | this.rtmpSendConnect(); 178 | } 179 | break; 180 | case RTMP_HANDSHAKE_2: 181 | return this.rtmpChunkRead(data, p, bytes); 182 | } 183 | } 184 | } 185 | 186 | onSocketError(e) { 187 | Logger.error('rtmp_client', 'onSocketError', e); 188 | this.isSocketOpen = false; 189 | this.stop(); 190 | } 191 | 192 | onSocketClose() { 193 | // Logger.debug('rtmp_client', "onSocketClose"); 194 | this.isSocketOpen = false; 195 | this.stop(); 196 | } 197 | 198 | onSocketTimeout() { 199 | // Logger.debug('rtmp_client', "onSocketTimeout"); 200 | this.isSocketOpen = false; 201 | this.stop(); 202 | } 203 | 204 | on(event, callback) { 205 | this.launcher.on(event, callback); 206 | } 207 | 208 | startPull() { 209 | this._start(); 210 | } 211 | 212 | startPush() { 213 | this.isPublish = true; 214 | this._start(); 215 | } 216 | 217 | _start() { 218 | this.socket = Net.createConnection(this.info.port, this.info.hostname, () => { 219 | //rtmp handshark c0c1 220 | let c0c1 = Crypto.randomBytes(1537); 221 | c0c1.writeUInt8(3); 222 | c0c1.writeUInt32BE(Date.now() / 1000, 1); 223 | c0c1.writeUInt32BE(0, 5); 224 | this.socket.write(c0c1); 225 | // Logger.debug('[rtmp client] write c0c1'); 226 | }); 227 | this.socket.on('data', this.onSocketData.bind(this)); 228 | this.socket.on('error', this.onSocketError.bind(this)); 229 | this.socket.on('close', this.onSocketClose.bind(this)); 230 | this.socket.on('timeout', this.onSocketTimeout.bind(this)); 231 | this.socket.setTimeout(60000); 232 | } 233 | 234 | stop() { 235 | if (this.streamId > 0) { 236 | if (!this.socket.destroyed) { 237 | if (this.isPublish) { 238 | this.rtmpSendFCUnpublish(); 239 | } 240 | this.rtmpSendDeleteStream(); 241 | this.socket.destroy(); 242 | } 243 | this.streamId = 0; 244 | this.launcher.emit('close'); 245 | } 246 | } 247 | 248 | pushAudio(audioData, timestamp) { 249 | if (this.streamId == 0) return; 250 | let packet = RtmpPacket.create(); 251 | packet.header.fmt = RTMP_CHUNK_TYPE_0; 252 | packet.header.cid = RTMP_CHANNEL_AUDIO; 253 | packet.header.type = RTMP_TYPE_AUDIO; 254 | packet.payload = audioData; 255 | packet.header.length = packet.payload.length; 256 | packet.header.timestamp = timestamp; 257 | let rtmpChunks = this.rtmpChunksCreate(packet); 258 | this.socket.write(rtmpChunks); 259 | } 260 | 261 | pushVideo(videoData, timestamp) { 262 | if (this.streamId == 0) return; 263 | let packet = RtmpPacket.create(); 264 | packet.header.fmt = RTMP_CHUNK_TYPE_0; 265 | packet.header.cid = RTMP_CHANNEL_VIDEO; 266 | packet.header.type = RTMP_TYPE_VIDEO; 267 | packet.payload = videoData; 268 | packet.header.length = packet.payload.length; 269 | packet.header.timestamp = timestamp; 270 | let rtmpChunks = this.rtmpChunksCreate(packet); 271 | this.socket.write(rtmpChunks); 272 | } 273 | 274 | pushScript(scriptData, timestamp) { 275 | if (this.streamId == 0) return; 276 | let packet = RtmpPacket.create(); 277 | packet.header.fmt = RTMP_CHUNK_TYPE_0; 278 | packet.header.cid = RTMP_CHANNEL_DATA; 279 | packet.header.type = RTMP_TYPE_DATA; 280 | packet.payload = scriptData; 281 | packet.header.length = packet.payload.length; 282 | packet.header.timestamp = timestamp; 283 | let rtmpChunks = this.rtmpChunksCreate(packet); 284 | this.socket.write(rtmpChunks); 285 | } 286 | 287 | rtmpUrlParser(url) { 288 | let urlInfo = Url.parse(url, true); 289 | urlInfo.app = urlInfo.path.split('/')[1]; 290 | urlInfo.port = urlInfo.port ? urlInfo.port : RTMP_PORT; 291 | urlInfo.tcurl = urlInfo.href.match(/rtmp:\/\/([^\/]+)\/([^\/]+)/)[0]; 292 | urlInfo.stream = urlInfo.path.slice(urlInfo.app.length + 2); 293 | return urlInfo; 294 | } 295 | 296 | rtmpChunkBasicHeaderCreate(fmt, cid) { 297 | let out; 298 | if (cid >= 64 + 255) { 299 | out = Buffer.alloc(3); 300 | out[0] = (fmt << 6) | 1; 301 | out[1] = (cid - 64) & 0xFF; 302 | out[2] = ((cid - 64) >> 8) & 0xFF; 303 | } else if (cid >= 64) { 304 | out = Buffer.alloc(2); 305 | out[0] = (fmt << 6) | 0; 306 | out[1] = (cid - 64) & 0xFF; 307 | } else { 308 | out = Buffer.alloc(1); 309 | out[0] = (fmt << 6) | cid; 310 | } 311 | return out; 312 | } 313 | 314 | rtmpChunkMessageHeaderCreate(header) { 315 | let out = Buffer.alloc(rtmpHeaderSize[header.fmt % 4]); 316 | if (header.fmt <= RTMP_CHUNK_TYPE_2) { 317 | out.writeUIntBE(header.timestamp >= 0xffffff ? 0xffffff : header.timestamp, 0, 3); 318 | } 319 | 320 | if (header.fmt <= RTMP_CHUNK_TYPE_1) { 321 | out.writeUIntBE(header.length, 3, 3); 322 | out.writeUInt8(header.type, 6); 323 | } 324 | 325 | if (header.fmt === RTMP_CHUNK_TYPE_0) { 326 | out.writeUInt32LE(header.stream_id, 7); 327 | } 328 | return out; 329 | } 330 | 331 | rtmpChunksCreate(packet) { 332 | let header = packet.header; 333 | let payload = packet.payload; 334 | let payloadSize = header.length; 335 | let chunkSize = this.outChunkSize; 336 | let chunksOffset = 0; 337 | let payloadOffset = 0; 338 | 339 | let chunkBasicHeader = this.rtmpChunkBasicHeaderCreate(header.fmt, header.cid); 340 | let chunkBasicHeader3 = this.rtmpChunkBasicHeaderCreate(RTMP_CHUNK_TYPE_3, header.cid); 341 | let chunkMessageHeader = this.rtmpChunkMessageHeaderCreate(header); 342 | let useExtendedTimestamp = header.timestamp >= 0xffffff; 343 | let headerSize = chunkBasicHeader.length + chunkMessageHeader.length + (useExtendedTimestamp ? 4 : 0); 344 | 345 | let n = headerSize + payloadSize + Math.floor(payloadSize / chunkSize); 346 | if (useExtendedTimestamp) { 347 | n += Math.floor(payloadSize / chunkSize) * 4; 348 | } 349 | if (!(payloadSize % chunkSize)) { 350 | n -= 1; 351 | if (useExtendedTimestamp) { //TODO CHECK 352 | n -= 4; 353 | } 354 | } 355 | 356 | let chunks = Buffer.alloc(n); 357 | chunkBasicHeader.copy(chunks, chunksOffset); 358 | chunksOffset += chunkBasicHeader.length; 359 | chunkMessageHeader.copy(chunks, chunksOffset); 360 | chunksOffset += chunkMessageHeader.length; 361 | if (useExtendedTimestamp) { 362 | chunks.writeUInt32BE(header.timestamp, chunksOffset); 363 | chunksOffset += 4; 364 | } 365 | while (payloadSize > 0) { 366 | if (payloadSize > chunkSize) { 367 | payload.copy(chunks, chunksOffset, payloadOffset, payloadOffset + chunkSize); 368 | payloadSize -= chunkSize; 369 | chunksOffset += chunkSize; 370 | payloadOffset += chunkSize; 371 | chunkBasicHeader3.copy(chunks, chunksOffset); 372 | chunksOffset += chunkBasicHeader3.length; 373 | if (useExtendedTimestamp) { 374 | chunks.writeUInt32BE(header.timestamp, chunksOffset); 375 | chunksOffset += 4; 376 | } 377 | } else { 378 | payload.copy(chunks, chunksOffset, payloadOffset, payloadOffset + payloadSize); 379 | payloadSize -= payloadSize; 380 | chunksOffset += payloadSize; 381 | payloadOffset += payloadSize; 382 | } 383 | } 384 | return chunks; 385 | } 386 | 387 | rtmpChunkRead(data, p, bytes) { 388 | let size = 0; 389 | let offset = 0; 390 | let extended_timestamp = 0; 391 | 392 | while (offset < bytes) { 393 | switch (this.parserState) { 394 | case RTMP_PARSE_INIT: 395 | this.parserBytes = 1; 396 | this.parserBuffer[0] = data[p + offset++]; 397 | if (0 === (this.parserBuffer[0] & 0x3F)) { 398 | this.parserBasicBytes = 2; 399 | } else if (1 === (this.parserBuffer[0] & 0x3F)) { 400 | this.parserBasicBytes = 3; 401 | } else { 402 | this.parserBasicBytes = 1; 403 | } 404 | this.parserState = RTMP_PARSE_BASIC_HEADER; 405 | break; 406 | case RTMP_PARSE_BASIC_HEADER: 407 | while (this.parserBytes < this.parserBasicBytes && offset < bytes) { 408 | this.parserBuffer[this.parserBytes++] = data[p + offset++]; 409 | } 410 | if (this.parserBytes >= this.parserBasicBytes) { 411 | this.parserState = RTMP_PARSE_MESSAGE_HEADER; 412 | } 413 | break; 414 | case RTMP_PARSE_MESSAGE_HEADER: 415 | size = rtmpHeaderSize[this.parserBuffer[0] >> 6] + this.parserBasicBytes; 416 | while (this.parserBytes < size && offset < bytes) { 417 | this.parserBuffer[this.parserBytes++] = data[p + offset++]; 418 | } 419 | if (this.parserBytes >= size) { 420 | this.rtmpPacketParse(); 421 | this.parserState = RTMP_PARSE_EXTENDED_TIMESTAMP; 422 | } 423 | break; 424 | case RTMP_PARSE_EXTENDED_TIMESTAMP: 425 | size = rtmpHeaderSize[this.parserPacket.header.fmt] + this.parserBasicBytes; 426 | if (this.parserPacket.header.timestamp === 0xFFFFFF) size += 4; 427 | while (this.parserBytes < size && offset < bytes) { 428 | this.parserBuffer[this.parserBytes++] = data[p + offset++]; 429 | } 430 | if (this.parserBytes >= size) { 431 | if (this.parserPacket.header.timestamp === 0xFFFFFF) { 432 | extended_timestamp = this.parserBuffer.readUInt32BE(rtmpHeaderSize[this.parserPacket.header.fmt] + this.parserBasicBytes); 433 | } 434 | 435 | if (0 === this.parserPacket.bytes) { 436 | if (RTMP_CHUNK_TYPE_0 === this.parserPacket.header.fmt) { 437 | this.parserPacket.clock = 0xFFFFFF === this.parserPacket.header.timestamp ? extended_timestamp : this.parserPacket.header.timestamp; 438 | this.parserPacket.delta = 0; 439 | } else { 440 | this.parserPacket.delta = 0xFFFFFF === this.parserPacket.header.timestamp ? extended_timestamp : this.parserPacket.header.timestamp; 441 | } 442 | this.rtmpPacketAlloc(); 443 | } 444 | this.parserState = RTMP_PARSE_PAYLOAD; 445 | } 446 | break; 447 | case RTMP_PARSE_PAYLOAD: 448 | size = Math.min(this.inChunkSize - (this.parserPacket.bytes % this.inChunkSize), this.parserPacket.header.length - this.parserPacket.bytes); 449 | size = Math.min(size, bytes - offset); 450 | if (size > 0) { 451 | data.copy(this.parserPacket.payload, this.parserPacket.bytes, p + offset, p + offset + size); 452 | } 453 | this.parserPacket.bytes += size; 454 | offset += size; 455 | 456 | if (this.parserPacket.bytes >= this.parserPacket.header.length) { 457 | this.parserState = RTMP_PARSE_INIT; 458 | this.parserPacket.bytes = 0; 459 | this.parserPacket.clock += this.parserPacket.delta; 460 | this.rtmpHandler(); 461 | } else if (0 === (this.parserPacket.bytes % this.inChunkSize)) { 462 | this.parserState = RTMP_PARSE_INIT; 463 | } 464 | break; 465 | } 466 | } 467 | } 468 | 469 | rtmpPacketParse() { 470 | let fmt = this.parserBuffer[0] >> 6; 471 | let cid = 0; 472 | if (this.parserBasicBytes === 2) { 473 | cid = 64 + this.parserBuffer[1]; 474 | } else if (this.parserBasicBytes === 3) { 475 | cid = 64 + this.parserBuffer[1] + this.parserBuffer[2] << 8; 476 | } else { 477 | cid = this.parserBuffer[0] & 0x3F; 478 | } 479 | let hasp = this.inPackets.has(cid); 480 | if (!hasp) { 481 | this.parserPacket = RtmpPacket.create(fmt, cid); 482 | this.inPackets.set(cid, this.parserPacket); 483 | } else { 484 | this.parserPacket = this.inPackets.get(cid); 485 | } 486 | this.parserPacket.header.fmt = fmt; 487 | this.parserPacket.header.cid = cid; 488 | this.rtmpChunkMessageHeaderRead(); 489 | // Logger.log(this.parserPacket); 490 | 491 | } 492 | 493 | rtmpChunkMessageHeaderRead() { 494 | let offset = this.parserBasicBytes; 495 | 496 | // timestamp / delta 497 | if (this.parserPacket.header.fmt <= RTMP_CHUNK_TYPE_2) { 498 | this.parserPacket.header.timestamp = this.parserBuffer.readUIntBE(offset, 3); 499 | offset += 3; 500 | } 501 | 502 | // message length + type 503 | if (this.parserPacket.header.fmt <= RTMP_CHUNK_TYPE_1) { 504 | this.parserPacket.header.length = this.parserBuffer.readUIntBE(offset, 3); 505 | this.parserPacket.header.type = this.parserBuffer[offset + 3]; 506 | offset += 4; 507 | } 508 | 509 | if (this.parserPacket.header.fmt === RTMP_CHUNK_TYPE_0) { 510 | this.parserPacket.header.stream_id = this.parserBuffer.readUInt32LE(offset); 511 | offset += 4; 512 | } 513 | return offset; 514 | } 515 | 516 | rtmpPacketAlloc() { 517 | if (this.parserPacket.capacity < this.parserPacket.header.length) { 518 | this.parserPacket.payload = Buffer.alloc(this.parserPacket.header.length + 1024); 519 | this.parserPacket.capacity = this.parserPacket.header.length + 1024; 520 | } 521 | } 522 | 523 | rtmpHandler() { 524 | switch (this.parserPacket.header.type) { 525 | case RTMP_TYPE_SET_CHUNK_SIZE: 526 | case RTMP_TYPE_ABORT: 527 | case RTMP_TYPE_ACKNOWLEDGEMENT: 528 | case RTMP_TYPE_WINDOW_ACKNOWLEDGEMENT_SIZE: 529 | case RTMP_TYPE_SET_PEER_BANDWIDTH: 530 | return 0 === this.rtmpControlHandler() ? -1 : 0; 531 | case RTMP_TYPE_EVENT: 532 | return 0 === this.rtmpEventHandler() ? -1 : 0; 533 | case RTMP_TYPE_AUDIO: 534 | return this.rtmpAudioHandler(); 535 | case RTMP_TYPE_VIDEO: 536 | return this.rtmpVideoHandler(); 537 | case RTMP_TYPE_FLEX_MESSAGE: 538 | case RTMP_TYPE_INVOKE: 539 | return this.rtmpInvokeHandler(); 540 | case RTMP_TYPE_FLEX_STREAM:// AMF3 541 | case RTMP_TYPE_DATA: // AMF0 542 | return this.rtmpDataHandler(); 543 | } 544 | } 545 | 546 | rtmpControlHandler() { 547 | let payload = this.parserPacket.payload; 548 | switch (this.parserPacket.header.type) { 549 | case RTMP_TYPE_SET_CHUNK_SIZE: 550 | this.inChunkSize = payload.readUInt32BE(); 551 | // Logger.debug('set inChunkSize', this.inChunkSize); 552 | break; 553 | case RTMP_TYPE_ABORT: 554 | break; 555 | case RTMP_TYPE_ACKNOWLEDGEMENT: 556 | break; 557 | case RTMP_TYPE_WINDOW_ACKNOWLEDGEMENT_SIZE: 558 | this.ackSize = payload.readUInt32BE(); 559 | // Logger.debug('set ack Size', this.ackSize); 560 | break; 561 | case RTMP_TYPE_SET_PEER_BANDWIDTH: 562 | break; 563 | } 564 | } 565 | 566 | rtmpEventHandler() { 567 | let payload = this.parserPacket.payload.slice(0, this.parserPacket.header.length); 568 | let event = payload.readUInt16BE(); 569 | let value = payload.readUInt32BE(2); 570 | // Logger.log('rtmpEventHandler', event, value); 571 | switch (event) { 572 | case 6: 573 | this.rtmpSendPingResponse(value); 574 | break; 575 | } 576 | } 577 | 578 | rtmpInvokeHandler() { 579 | let offset = this.parserPacket.header.type === RTMP_TYPE_FLEX_MESSAGE ? 1 : 0; 580 | let payload = this.parserPacket.payload.slice(offset, this.parserPacket.header.length); 581 | let invokeMessage = AMF.decodeAmf0Cmd(payload); 582 | // Logger.log('rtmpInvokeHandler', invokeMessage); 583 | 584 | switch (invokeMessage.cmd) { 585 | case '_result': 586 | this.rtmpCommandOnresult(invokeMessage); 587 | break; 588 | case '_error': 589 | this.rtmpCommandOnerror(invokeMessage); 590 | break; 591 | case 'onStatus': 592 | this.rtmpCommandOnstatus(invokeMessage); 593 | break; 594 | } 595 | } 596 | 597 | rtmpCommandOnresult(invokeMessage) { 598 | // Logger.debug(invokeMessage); 599 | switch (invokeMessage.transId) { 600 | case RTMP_TRANSACTION_CONNECT: 601 | this.launcher.emit('status', invokeMessage.info); 602 | this.rtmpOnconnect(); 603 | break; 604 | case RTMP_TRANSACTION_CREATE_STREAM: 605 | this.rtmpOncreateStream(invokeMessage.info); 606 | break; 607 | } 608 | } 609 | 610 | rtmpCommandOnerror(invokeMessage) { 611 | this.launcher.emit('status', invokeMessage.info); 612 | } 613 | 614 | rtmpCommandOnstatus(invokeMessage) { 615 | this.launcher.emit('status', invokeMessage.info); 616 | } 617 | 618 | rtmpOnconnect() { 619 | if (this.isPublish) { 620 | this.rtmpSendReleaseStream(); 621 | this.rtmpSendFCPublish(); 622 | } 623 | this.rtmpSendCreateStream(); 624 | } 625 | 626 | rtmpOncreateStream(sid) { 627 | this.streamId = sid; 628 | if (this.isPublish) { 629 | this.rtmpSendPublish(); 630 | this.rtmpSendSetChunkSize(); 631 | } else { 632 | this.rtmpSendPlay(); 633 | this.rtmpSendSetBufferLength(1000); 634 | } 635 | } 636 | 637 | rtmpAudioHandler() { 638 | let payload = this.parserPacket.payload.slice(0, this.parserPacket.header.length); 639 | this.launcher.emit('audio', payload, this.parserPacket.clock); 640 | } 641 | 642 | rtmpVideoHandler() { 643 | let payload = this.parserPacket.payload.slice(0, this.parserPacket.header.length); 644 | this.launcher.emit('video', payload, this.parserPacket.clock); 645 | } 646 | 647 | rtmpDataHandler() { 648 | let payload = this.parserPacket.payload.slice(0, this.parserPacket.header.length); 649 | this.launcher.emit('script', payload, this.parserPacket.clock); 650 | } 651 | 652 | sendInvokeMessage(sid, opt) { 653 | let packet = RtmpPacket.create(); 654 | packet.header.fmt = RTMP_CHUNK_TYPE_0; 655 | packet.header.cid = RTMP_CHANNEL_INVOKE; 656 | packet.header.type = RTMP_TYPE_INVOKE; 657 | packet.header.stream_id = sid; 658 | packet.payload = AMF.encodeAmf0Cmd(opt); 659 | packet.header.length = packet.payload.length; 660 | let chunks = this.rtmpChunksCreate(packet); 661 | this.socket.write(chunks); 662 | } 663 | 664 | rtmpSendConnect() { 665 | let opt = { 666 | cmd: 'connect', 667 | transId: RTMP_TRANSACTION_CONNECT, 668 | cmdObj: { 669 | app: this.info.app, 670 | flashVer: FLASHVER, 671 | tcUrl: this.info.tcurl, 672 | fpad: 0, 673 | capabilities: 15, 674 | audioCodecs: 3191, 675 | videoCodecs: 252, 676 | videoFunction: 1, 677 | encoding: 0 678 | } 679 | }; 680 | this.sendInvokeMessage(0, opt); 681 | } 682 | 683 | rtmpSendReleaseStream() { 684 | let opt = { 685 | cmd: 'releaseStream', 686 | transId: 0, 687 | cmdObj: null, 688 | streamName: this.info.stream, 689 | }; 690 | this.sendInvokeMessage(this.streamId, opt); 691 | } 692 | 693 | rtmpSendFCPublish() { 694 | let opt = { 695 | cmd: 'FCPublish', 696 | transId: 0, 697 | cmdObj: null, 698 | streamName: this.info.stream, 699 | }; 700 | this.sendInvokeMessage(this.streamId, opt); 701 | } 702 | 703 | rtmpSendCreateStream() { 704 | let opt = { 705 | cmd: 'createStream', 706 | transId: RTMP_TRANSACTION_CREATE_STREAM, 707 | cmdObj: null 708 | }; 709 | this.sendInvokeMessage(0, opt); 710 | } 711 | 712 | rtmpSendPlay() { 713 | let opt = { 714 | cmd: 'play', 715 | transId: 0, 716 | cmdObj: null, 717 | streamName: this.info.stream, 718 | start: -2, 719 | duration: -1, 720 | reset: 1 721 | }; 722 | this.sendInvokeMessage(this.streamId, opt); 723 | } 724 | 725 | rtmpSendSetBufferLength(bufferTime) { 726 | let packet = RtmpPacket.create(); 727 | packet.header.fmt = RTMP_CHUNK_TYPE_0; 728 | packet.header.cid = RTMP_CHANNEL_PROTOCOL; 729 | packet.header.type = RTMP_TYPE_EVENT; 730 | packet.payload = Buffer.alloc(10); 731 | packet.header.length = packet.payload.length; 732 | packet.payload.writeUInt16BE(0x03); 733 | packet.payload.writeUInt32BE(this.streamId, 2); 734 | packet.payload.writeUInt32BE(bufferTime, 6); 735 | let chunks = this.rtmpChunksCreate(packet); 736 | this.socket.write(chunks); 737 | } 738 | 739 | rtmpSendPublish() { 740 | let opt = { 741 | cmd: 'publish', 742 | transId: 0, 743 | cmdObj: null, 744 | streamName: this.info.stream, 745 | type: 'live' 746 | }; 747 | this.sendInvokeMessage(this.streamId, opt); 748 | } 749 | 750 | rtmpSendSetChunkSize() { 751 | let rtmpBuffer = Buffer.from('02000000000004010000000000000000', 'hex'); 752 | rtmpBuffer.writeUInt32BE(this.inChunkSize, 12); 753 | this.socket.write(rtmpBuffer); 754 | this.outChunkSize = this.inChunkSize; 755 | } 756 | 757 | rtmpSendFCUnpublish() { 758 | let opt = { 759 | cmd: 'FCUnpublish', 760 | transId: 0, 761 | cmdObj: null, 762 | streamName: this.info.stream, 763 | }; 764 | this.sendInvokeMessage(this.streamId, opt); 765 | } 766 | 767 | rtmpSendDeleteStream() { 768 | let opt = { 769 | cmd: 'deleteStream', 770 | transId: 0, 771 | cmdObj: null, 772 | streamId: this.streamId 773 | }; 774 | this.sendInvokeMessage(this.streamId, opt); 775 | } 776 | 777 | rtmpSendPingResponse(time) { 778 | let packet = RtmpPacket.create(); 779 | packet.header.fmt = RTMP_CHUNK_TYPE_0; 780 | packet.header.cid = RTMP_CHANNEL_PROTOCOL; 781 | packet.header.type = RTMP_TYPE_EVENT; 782 | packet.payload = Buffer.alloc(6); 783 | packet.header.length = packet.payload.length; 784 | packet.payload.writeUInt16BE(0x07); 785 | packet.payload.writeUInt32BE(time, 2); 786 | let chunks = this.rtmpChunksCreate(packet); 787 | this.socket.write(chunks); 788 | } 789 | } 790 | 791 | module.exports = NodeRtmpClient; --------------------------------------------------------------------------------