UNPKG

nvr-js

Version:

A simple, lightweight, but very functional NVR aimed at 24/7 recording using nodejs

608 lines (548 loc) 15 kB
const express = require('express'); const cookieparser = require('cookie-parser'); const cookie = require('cookie'); const bcrypt = require('bcrypt'); const http = require('http'); const io = require('socket.io'); const handlebars = require('handlebars'); const childprocess = require('child_process'); const MP4Frag = require('./core/MP4Frag'); const fs = require('fs'); const os = require('os'); const path = require('path'); const sql = require('sqlite3'); const osu = require('node-os-utils'); const dayjs = require('dayjs'); const queue = require('queue-fifo'); const customParseFormat = require('dayjs/plugin/customParseFormat'); dayjs.extend(customParseFormat); const RateLimiter = require('express-rate-limit'); console.log(' - Checking config.'); if (!fs.existsSync(path.join(os.homedir(), 'nvrjs.config.js'))) { fs.copyFileSync( path.join(__dirname, 'nvrjs.config.example.js'), path.join(os.homedir(), 'nvrjs.config.js') ); console.log( ' - New config created: ' + path.join(os.homedir(), 'nvrjs.config.js') ); console.log(' - Edit config to suite and restart!'); process.exit(0); } const config = require(path.join(os.homedir(), 'nvrjs.config.js')); console.log(' - Config loaded: ' + path.join(os.homedir(), 'nvrjs.config.js')); let SQL; const SensorTimestamps = {}; console.log(' - Checking volumes and ffmpeg.'); if (!fs.existsSync(config.system.storageVolume)) { console.log(' - Storage volume does not exist'); process.exit(); } else { try { if ( !fs.existsSync(path.join(config.system.storageVolume, 'NVRJS_SYSTEM')) ) { fs.mkdirSync(path.join(config.system.storageVolume, 'NVRJS_SYSTEM')); } if ( !fs.existsSync( path.join(config.system.storageVolume, 'NVRJS_CAMERA_RECORDINGS') ) ) { fs.mkdirSync( path.join(config.system.storageVolume, 'NVRJS_CAMERA_RECORDINGS') ); } } catch (e) { console.log('Error creating system directories.'); console.log(e.message); process.exit(0); } } if (!fs.existsSync(config.system.ffmpegLocation)) { console.log( 'ffmpeg not found in specifed location: ' + config.system.ffmpegLocation ); process.exit(0); } CreateOrConnectSQL(() => { console.log(' - Starting purge interval.'); setInterval( purgeContinuous, 1000 * 3600 * config.system.continuousPurgeIntervalHours ); purgeContinuous(); }); console.log(' - Starting data write queue.'); const FIFO = new queue(); function Commit() { if (!FIFO.isEmpty()) { const Query = FIFO.dequeue(); const STMT = SQL.prepare(Query.statement, () => { STMT.run(Query.params, () => { STMT.finalize(); Commit(); }); }); } else { setTimeout(Commit, 10000); } } setTimeout(Commit, 10000); const IOLimiter = RateLimiter({ windowMs: 2000, max: 100 }); console.log(' - Creating express application.'); const App = new express(); App.use(IOLimiter); App.use(express.json()); App.use(cookieparser(config.system.cookieKey)); const HTTP = new http.Server(App); console.log(' - Compiling pages.'); const CompiledPages = {}; const Pages = { Dash: path.join(__dirname, 'web', 'dash.html'), Index: path.join(__dirname, 'web', 'index.html') }; Object.keys(Pages).forEach((PS) => { CompiledPages[PS] = handlebars.compile(fs.readFileSync(Pages[PS], 'utf8')); }); // Static App.use('/static', express.static(path.join(__dirname, 'web', 'static'))); // UI App.get('/', (req, res) => { res.type('text/html'); res.status(200); res.end(CompiledPages.Index()); }); App.post('/login', (req, res) => { const Data = req.body; const Password = Data.password; const Username = Data.username; if ( bcrypt.compareSync(Password, config.system.password) && config.system.username === Username ) { res.cookie('Authentication', 'Success', { signed: true }); res.status(204); res.end(); } else { res.status(401); res.end(); } }); App.get('/dashboard', CheckAuthMW, (req, res) => { res.type('text/html'); res.status(200); res.end(CompiledPages.Dash(config)); }); // System Info App.get('/api/:APIKey/systeminfo', (req, res) => { if (bcrypt.compareSync(req.params.APIKey, config.system.apiKey)) { getSystemInfo(req, res); } else { res.status(401); res.end(); } }); App.get('/systeminfo', CheckAuthMW, (req, res) => { getSystemInfo(req, res); }); function getSystemInfo(req, res) { osu.cpu.usage().then((CPU) => { osu.drive.info(config.system.storageVolume).then((DISK) => { osu.mem.info().then((MEM) => { const Info = { CPU: CPU, DISK: DISK, MEM: MEM }; res.type('application/json'); res.status(200); res.end(JSON.stringify(Info)); }); }); }); } // get Cameras App.get('/api/:APIKey/cameras', (req, res) => { if (bcrypt.compareSync(req.params.APIKey, config.system.apiKey)) { const Cams = []; Object.keys(config.cameras).forEach((ID) => { const Cam = config.cameras[ID]; Cams.push({ id: ID, name: Cam.name, continuous: Cam.continuous }); }); res.type('application/json'); res.status(200); res.end(JSON.stringify(Cams)); } else { res.status(401); res.end(); } }); // Event Creation App.post('/api/:APIKey/event/:CameraID', (req, res) => { if (bcrypt.compareSync(req.params.APIKey, config.system.apiKey)) { if (config.cameras[req.params.CameraID].continuous) { if (!SensorTimestamps.hasOwnProperty(req.body.sensorId)) { FIFO.enqueue({ statement: 'INSERT INTO Events(EventID,CameraID,Name,SensorID,Date) VALUES(?,?,?,?,?)', params: [ generateUUID(), req.params.CameraID, req.body.name, req.body.sensorId, req.body.date ] }); res.status(204); res.end(); SensorTimestamps[req.body.sensorId] = dayjs().unix(); setTimeout(() => { delete SensorTimestamps[req.body.sensorId]; }, 1000 * config.system.eventSensorIdCoolOffSeconds); return; } else { res.status(429); res.end(); } } else { res.status(501); res.end(); } } else { res.status(401); res.end(); } }); // Snapshot App.get('/snapshot/:CameraID/:Width', CheckAuthMW, (req, res) => { getSnapShot(res, req.params.CameraID, req.params.Width); }); App.get('/api/:APIKey/snapshot/:CameraID/:Width', (req, res) => { if (bcrypt.compareSync(req.params.APIKey, config.system.apiKey)) { getSnapShot(res, req.params.CameraID, req.params.Width); } else { res.status(401); res.end(); } }); function getSnapShot(Res, CameraID, Width) { const CommandArgs = []; const Cam = config.cameras[CameraID]; Object.keys(Cam.inputConfig).forEach((inputConfigKey) => { CommandArgs.push('-' + inputConfigKey); if (Cam.inputConfig[inputConfigKey].length > 0) { CommandArgs.push(Cam.inputConfig[inputConfigKey]); } }); CommandArgs.push('-i'); CommandArgs.push(Cam.input); CommandArgs.push('-vf'); CommandArgs.push('scale=' + Width + ':-1'); CommandArgs.push('-vframes'); CommandArgs.push('1'); CommandArgs.push('-f'); CommandArgs.push('image2'); CommandArgs.push('-'); const Process = childprocess.spawn( config.system.ffmpegLocation, CommandArgs, { env: process.env, stderr: 'ignore' } ); let imageBuffer = Buffer.alloc(0); Process.stdout.on('data', function (data) { imageBuffer = Buffer.concat([imageBuffer, data]); }); Process.on('exit', (Code, Signal) => { const _Error = FFMPEGExitDueToError(Code, Signal); if (!_Error) { Res.type('image/jpeg'); Res.status(200); Res.end(Buffer.from(imageBuffer, 'binary')); } else { Res.status(500); Res.end(); } }); } // Get Event Data App.get('/api/:APIKey/geteventdata/:CameraID/:Start/:End', (req, res) => { if (bcrypt.compareSync(req.params.APIKey, config.system.apiKey)) { GetEventData(res, req.params.CameraID, req.params.Start, req.params.End); } else { res.status(401); res.end(); } }); App.get('/geteventdata/:CameraID/:Start/:End', CheckAuthMW, (req, res) => { GetEventData(res, req.params.CameraID, req.params.Start, req.params.End); }); function GetEventData(res, CameraID, Start, End) { const Data = {}; let STMT = SQL.prepare( 'SELECT * FROM Segments WHERE CameraID = ? AND Start >= ? AND End <= ?' ); STMT.all([CameraID, parseInt(Start), parseInt(End)], (err, rows) => { Data.segments = rows; STMT.finalize(); STMT = SQL.prepare( 'SELECT * FROM Events WHERE CameraID = ? AND Date >= ? AND Date <= ?' ); STMT.all([CameraID, parseInt(Start), parseInt(End)], (err, rows) => { Data.events = rows; STMT.finalize(); res.type('application/json'); res.status(200); res.end(JSON.stringify(Data)); }); }); } const Processors = {}; const Cameras = Object.keys(config.cameras); Cameras.forEach((cameraID) => { const Cam = config.cameras[cameraID]; InitCamera(Cam, cameraID); }); function CreateOrConnectSQL(CB) { const Path = path.join( config.system.storageVolume, 'NVRJS_SYSTEM', 'data.db' ); if (!fs.existsSync(Path)) { console.log(' - Creating db structure.'); SQL = new sql.Database(Path, () => { SQL.run( 'CREATE TABLE Segments(SegmentID TEXT, CameraID TEXT, FileName TEXT, Start NUMERIC, End NUMERIC)', () => { SQL.run( 'CREATE TABLE Events(EventID TEXT,CameraID TEXT, Name TEXT, SensorID TEXT, Date NUMERIC)', () => { SQL.close(); console.log(' - Connecting to db.'); SQL = new sql.Database(Path, CB); } ); } ); }); } else { console.log(' - Connecting to db.'); SQL = new sql.Database(Path, CB); } } function FFMPEGExitDueToError(Code, Signal) { if (Code == null && Signal === 'SIGKILL') { return false; } if (Code === 255 && Signal == null) { return false; } if (Code > 0 && Code < 255 && Signal == null) { return true; } } function InitCamera(Cam, cameraID) { console.log(' - Configuring camera: ' + Cam.name); const CommandArgs = []; Object.keys(Cam.inputConfig).forEach((inputConfigKey) => { if (inputConfigKey !== 'i') { CommandArgs.push('-' + inputConfigKey); if (Cam.inputConfig[inputConfigKey].length > 0) { CommandArgs.push(Cam.inputConfig[inputConfigKey]); } } }); CommandArgs.push('-i'); CommandArgs.push(Cam.input); App.use( '/segments/' + cameraID, CheckAuthMW, express.static( path.join( config.system.storageVolume, 'NVRJS_CAMERA_RECORDINGS', cameraID ), { acceptRanges: true } ) ); const Path = path.join( config.system.storageVolume, 'NVRJS_CAMERA_RECORDINGS', cameraID ); if (!fs.existsSync(Path)) { fs.mkdirSync(Path); } if (Cam.continuous !== undefined && Cam.continuous) { CommandArgs.push('-c:v'); CommandArgs.push('copy'); CommandArgs.push('-c:a'); CommandArgs.push('copy'); CommandArgs.push('-f'); CommandArgs.push('segment'); CommandArgs.push('-movflags'); CommandArgs.push('+faststart'); CommandArgs.push('-segment_atclocktime'); CommandArgs.push('1'); CommandArgs.push('-reset_timestamps'); CommandArgs.push('1'); CommandArgs.push('-strftime'); CommandArgs.push('1'); CommandArgs.push('-segment_list'); CommandArgs.push('pipe:4'); CommandArgs.push('-segment_time'); CommandArgs.push(60 * config.system.continuousSegTimeMinutes); CommandArgs.push(path.join(Path, '%Y-%m-%dT%H-%M-%S.mp4')); } Object.keys(Cam.liveConfig.streamConfig).forEach((streamingConfigKey) => { CommandArgs.push('-' + streamingConfigKey); if (Cam.liveConfig.streamConfig[streamingConfigKey].length > 0) { CommandArgs.push(Cam.liveConfig.streamConfig[streamingConfigKey]); } }); CommandArgs.push('-metadata'); CommandArgs.push('title="NVR JS Stream"'); CommandArgs.push('pipe:3'); const Options = { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] }; const respawn = (Spawned) => { const MP4F = new MP4Frag(); const IOptions = { path: '/streams/' + cameraID }; const Socket = io(HTTP, IOptions); Socket.on('connection', (ClientSocket) => { if (CheckAuthMW(ClientSocket)) { ClientSocket.emit('segment', MP4F.initialization); } }); MP4F.on('segment', (data) => { Socket.sockets.sockets.forEach((ClientSocket) => { ClientSocket.emit('segment', data); }); }); Spawned.on('close', () => { console.log( ' - Camera: ' + Cam.name + ' was terminated, respawning after 10 seconds...' ); Spawned.kill(); MP4F.destroy(); setTimeout(() => { respawn( childprocess.spawn(config.system.ffmpegLocation, CommandArgs, Options) ); }, 10000); }); Spawned.stdio[3].on('data', (data) => { MP4F.write(data, 'binary'); }); Spawned.stdio[4].on('data', (FN) => { if (Processors[cameraID] !== undefined) { const FileName = FN.toString().trim().replace(/\n/g, ''); const Start = dayjs( FileName.replace(/.mp4/g, ''), 'YYYY-MM-DDTHH-mm-ss' ).unix(); const End = dayjs().unix(); FIFO.enqueue({ statement: 'INSERT INTO Segments(SegmentID,CameraID,FileName,Start,End) VALUES(?,?,?,?,?)', params: [generateUUID(), cameraID, FileName, Start, End] }); } }); }; respawn( childprocess.spawn(config.system.ffmpegLocation, CommandArgs, Options) ); Processors[cameraID] = { CameraInfo: Cam }; } function generateUUID() { var d = new Date().getTime(); var d2 = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0; return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16; if (d > 0) { r = (d + r) % 16 | 0; d = Math.floor(d / 16); } else { r = (d2 + r) % 16 | 0; d2 = Math.floor(d2 / 16); } return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } function CheckAuthMW(req, res, next) { if (res === undefined && next === undefined) { if (req.handshake.headers.cookie !== undefined) { const CS = cookie.parse(req.handshake.headers.cookie); const Signed = cookieparser.signedCookies(CS, config.system.cookieKey); if ( Signed.Authentication === undefined || Signed.Authentication !== 'Success' ) { req.disconnect(); return false; } else { return true; } } else { req.disconnect(); return false; } } else { if ( req.signedCookies.Authentication === undefined || req.signedCookies.Authentication !== 'Success' ) { res.status(401); res.end(); } else { next(); } } } async function purgeContinuous() { console.log(' - Purging data.'); const Date = dayjs().subtract(config.system.continuousDays, 'day').unix(); const STMT = SQL.prepare('SELECT * FROM Segments WHERE Start <= ?'); STMT.all([Date], (err, rows) => { rows.forEach((S) => { fs.unlinkSync( path.join( config.system.storageVolume, 'NVRJS_CAMERA_RECORDINGS', S.CameraID, S.FileName ) ); }); FIFO.enqueue({ statement: `DELETE FROM Segments WHERE Start <= ${Date}`, params: [] }); FIFO.enqueue({ statement: `DELETE FROM Events WHERE Date <= ${Date}`, params: [] }); }); } HTTP.listen(config.system.interfacePort); console.log(' - NVR JS is Ready!');