diff --git a/languages/en_CA.json b/languages/en_CA.json index 39b0af44..801ee1d0 100644 --- a/languages/en_CA.json +++ b/languages/en_CA.json @@ -468,6 +468,8 @@ "Max Storage Amount": "Max Storage Amount", "Video Share": "Video Share", "FileBin": "FileBin", + "File Saved": "File Saved", + "checkFileBinForNewFile": "Check the FileBin for the newly created file.", "File Download Ready": "File Download Ready", "Timelapse Video Build Complete": "Timelapse Video Build Complete", "yourFileDownloadedShortly": "Please wait. Your file will be downloaded shortly.", @@ -570,6 +572,10 @@ "clientStreamFailedattemptingReconnect": "Client side stream check failed, attempting reconnect.", "Export Video": "Export Video", "Merge Video": "Merge Video", + "Merge Videos": "Merge Videos", + "Merge": "Merge", + "MergeAllSelected": "Merge all selected Videos?", + "MergeAllInRange": "Merge all Videos in date range selected?", "Delete Filter": "Delete Filter", "Delete Files": "Delete Files", "confirmDeleteFilter": "Do you want to delete this filter? You cannot recover it.", @@ -1093,6 +1099,7 @@ "Can't Connect": "Can't Connect", "Video Finished": "Video Finished", "No Monitors Selected": "No Monitors Selected", + "No Monitor Selected": "No Monitor Selected", "Nothing Selected": "Nothing Selected", "makeASelection": "Make a selection and try again.", "monSavedButNotCopied": "Your monitor was saved but not copied to any other monitor.", diff --git a/libs/fileBin.js b/libs/fileBin.js index 82bdaa31..4ff6a94b 100644 --- a/libs/fileBin.js +++ b/libs/fileBin.js @@ -192,6 +192,11 @@ module.exports = function(s,config,lang,app,io){ }) }) } + s.notifyFileBinUploaded = function(fileBinInsertQuery){ + s.tx(Object.assign({ + f: 'fileBin_item_added', + },fileBinInsertQuery),'GRP_'+fileBinInsertQuery.ke); + } s.getFileBinDirectory = getFileBinDirectory s.getFileBinEntry = getFileBinEntry s.getFileBinBuffer = getFileBinBuffer diff --git a/libs/video/utils.js b/libs/video/utils.js index 65b33202..c750211c 100644 --- a/libs/video/utils.js +++ b/libs/video/utils.js @@ -1,6 +1,8 @@ const fs = require('fs') const { spawn } = require('child_process') const async = require('async'); +const path = require('path'); +const fsP = require('fs').promises; module.exports = (s,config,lang) => { const { ffprobe, @@ -593,6 +595,7 @@ module.exports = (s,config,lang) => { time: video.time, } await s.insertFileBinEntry(fileBinInsertQuery) + s.notifyFileBinUploaded(fileBinInsertQuery) s.tx(Object.assign({ f: 'fileBin_item_added', slicedVideo: true, @@ -604,6 +607,149 @@ module.exports = (s,config,lang) => { } return response } + const mergingVideos = {}; + const mergeVideos = async function({ + groupKey, + monitorId, + filePaths, + outputFilePath, + videoCodec = 'libx265', + audioCodec = 'aac', + onStdout = (data) => {s.systemLog(`${data}`)}, + onStderr = (data) => {s.systemLog(`${data}`)}, + }) { + if (!Array.isArray(filePaths) || filePaths.length === 0) { + throw new Error('First parameter must be a non-empty array of absolute file paths.'); + } + if(mergingVideos[outputFilePath])return; + const currentDate = new Date(); + const fileExtensions = filePaths.map(file => path.extname(file).toLowerCase()); + const allSameExtension = fileExtensions.every(ext => ext === fileExtensions[0]); + const fileList = filePaths.map(file => `file '${file}'`).join('\n'); + const tempFileListPath = path.join(s.dir.streams, groupKey, monitorId, `video_merge_${currentDate}.txt`); + mergingVideos[outputFilePath] = currentDate; + try { + await fsP.writeFile(tempFileListPath, fileList); + let ffmpegArgs; + // if (allSameExtension) { + // ffmpegArgs = [ + // '-f', 'concat', + // '-safe', '0', + // '-i', tempFileListPath, + // '-c', 'copy', + // '-y', + // outputFilePath + // ]; + // } else { + ffmpegArgs = [ + '-loglevel', 'warning', + '-f', 'concat', + '-safe', '0', + '-i', tempFileListPath, + '-c:v', videoCodec, + '-c:a', audioCodec, + '-strict', '-2', + '-crf', '1', + '-y', + outputFilePath + ]; + // } + s.debugLog(fileList) + s.debugLog(ffmpegArgs) + + await new Promise((resolve, reject) => { + const ffmpegProcess = spawn(config.ffmpegDir, ffmpegArgs); + ffmpegProcess.stdout.on('data', onStdout); + ffmpegProcess.stderr.on('data', onStderr); + ffmpegProcess.on('close', (code) => { + delete(mergingVideos[outputFilePath]); + if (code === 0) { + console.log(`FFmpeg process exited with code ${code}`); + resolve(); + } else { + reject(new Error(`FFmpeg process exited with code ${code}`)); + } + }); + ffmpegProcess.on('error', (err) => { + reject(new Error(`FFmpeg error: ${err}`)); + }); + }); + } finally { + await fsP.unlink(tempFileListPath); + } + }; + async function mergeVideosAndBin(videos){ + const currentTime = new Date(); + const firstVideo = videos[0]; + const lastVideo = videos[videos.length - 1]; + const groupKey = firstVideo.ke; + const monitorId = firstVideo.mid; + const logTarget = { ke: groupKey, mid: '$USER' }; + try{ + try{ + await fsP.stat(outputFilePath) + return outputFilePath + }catch(err){ + + } + const filePaths = videos.map(video => { + const monitorConfig = s.group[video.ke].rawMonitorConfigurations[video.mid]; + const filePath = path.join(s.getVideoDirectory(video), `${s.formattedTime(video.time)}.mp4`); + return filePath + }); + const filename = `${s.formattedTime(firstVideo.time)}-${s.formattedTime(lastVideo.time)}-${filePaths.length}.mp4` + const fileBinFolder = s.getFileBinDirectory(firstVideo); + const outputFilePath = path.join(fileBinFolder, filename); + + s.userLog(logTarget,{ + type: 'mergeVideos ffmpeg START', + msg: { + monitorId, + numberOfVideos: filePaths.length, + } + }); + await mergeVideos({ + groupKey, + monitorId, + filePaths, + outputFilePath, + onStdout: (data) => { + s.debugLog(data.toString()) + s.userLog(logTarget,{ + type: 'mergeVideos ffmpeg LOG', + msg: `${data}` + }); + }, + onStderr: (data) => { + s.debugLog(data.toString()) + s.userLog(logTarget,{ + type: 'mergeVideos ffmpeg ERROR', + msg: `${data}` + }); + }, + }); + const fileSize = (await fsP.stat(outputFilePath)).size; + const fileBinInsertQuery = { + ke: groupKey, + mid: monitorId, + name: filename, + size: fileSize, + details: {}, + status: 1, + time: currentTime, + } + await s.insertFileBinEntry(fileBinInsertQuery); + s.notifyFileBinUploaded(fileBinInsertQuery); + return outputFilePath + }catch(err){ + console.log('mergeVideos process ERROR', err) + s.userLog(logTarget,{ + type: 'mergeVideos process ERROR', + msg: `${err.toString()}` + }); + return null; + } + } return { reEncodeVideoAndReplace, stitchMp4Files, @@ -615,5 +761,7 @@ module.exports = (s,config,lang) => { reEncodeVideoAndBinOriginalAddToQueue, archiveVideo, sliceVideo, + mergeVideos, + mergeVideosAndBin, } } diff --git a/libs/webServerPaths.js b/libs/webServerPaths.js index 217e83cd..30cc1cf6 100644 --- a/libs/webServerPaths.js +++ b/libs/webServerPaths.js @@ -39,6 +39,7 @@ module.exports = function(s,config,lang,app,io){ reEncodeVideoAndReplace, reEncodeVideoAndBinOriginalAddToQueue, getVideosBasedOnTagFoundInMatrixOfAssociatedEvent, + mergeVideosAndBin, } = require('./video/utils.js')(s,config,lang) s.renderPage = function(req,res,paths,passables,callback){ passables.window = {} @@ -1970,7 +1971,69 @@ module.exports = function(s,config,lang,app,io){ res.end(s.prettyPrint(response)); }) },res,req); - }) + }); + /** + * API : Merge Videos and Bin it + */ + app.post(config.webPaths.apiPrefix+':auth/mergeVideos/:ke/:id', function (req,res){ + s.auth(req.params, async function(user){ + const monitorId = req.params.id + const groupKey = req.params.ke + const { + monitorPermissions, + monitorRestrictions, + } = s.getMonitorsPermitted(user.details,monitorId,'video_view') + const { + isRestricted, + isRestrictedApiKey, + apiKeyPermissions, + } = s.checkPermission(user); + if( + isRestrictedApiKey && apiKeyPermissions.watch_videos_disallowed || + isRestricted && ( + monitorId && !monitorPermissions[`${monitorId}_video_view`] || + monitorRestrictions.length === 0 + ) + ){ + s.closeJsonResponse(res,{ok: false, msg: lang['Not Authorized'], videos: []}); + return + } + const response = { ok: false } + const selectedVideos = s.getPostData(req,'videos'); + console.log('selected',selectedVideos) + if(selectedVideos && selectedVideos.length > 1){ + const mergedFilePath = await mergeVideosAndBin(selectedVideos); + response.ok = !!mergedFilePath; + s.closeJsonResponse(res, response); + }else{ + s.sqlQueryBetweenTimesWithPermissions({ + table: 'Videos', + user: user, + noCount: true, + groupKey, + monitorId, + startTime: s.getPostData(req,'start'), + endTime: s.getPostData(req,'end'), + startTimeOperator: s.getPostData(req,'startOperator'), + endTimeOperator: s.getPostData(req,'endOperator'), + noLimit: s.getPostData(req,'noLimit'), + limit: s.getPostData(req,'limit'), + archived: s.getPostData(req,'archived'), + endIsStartTo: !!s.getPostData(req,'endIsStartTo'), + parseRowDetails: false, + rowName: 'videos', + monitorRestrictions: monitorRestrictions, + preliminaryValidationFailed: false + }, async ({ videos }) => { + if(videos){ + const mergedFilePath = await mergeVideosAndBin(videos); + response.ok = !!mergedFilePath; + } + s.closeJsonResponse(res, response); + }) + } + },res,req); + }); /** * API : Get Login Tokens */ diff --git a/web/assets/js/bs5.monitorsUtils.js b/web/assets/js/bs5.monitorsUtils.js index 392f447d..6ee09b4a 100644 --- a/web/assets/js/bs5.monitorsUtils.js +++ b/web/assets/js/bs5.monitorsUtils.js @@ -1168,6 +1168,9 @@ function getRunningMonitors(asArray){ }) return asArray ? Object.values(foundMonitors) : foundMonitors } +function buildFileBinUrl(data){ + return apiBaseUrl + '/fileBin/' + data.ke + '/' + data.mid + '/' + data.name +} $(document).ready(function(){ $('body') .on('click','[system]',function(){ diff --git a/web/assets/js/bs5.timelapseViewer.js b/web/assets/js/bs5.timelapseViewer.js index cc6b00c8..4d44594e 100644 --- a/web/assets/js/bs5.timelapseViewer.js +++ b/web/assets/js/bs5.timelapseViewer.js @@ -311,9 +311,6 @@ $(document).ready(function(e){ function downloadTimelapseFrame(frame){ downloadFile(frame.href,frame.filename) } - function buildFileBinUrl(data){ - return apiBaseUrl + '/fileBin/' + data.ke + '/' + data.mid + '/' + data.name - } function downloadTimelapseVideo(data){ var downloadUrl = buildFileBinUrl(data) downloadFile(downloadUrl,data.name) diff --git a/web/assets/js/bs5.videos.js b/web/assets/js/bs5.videos.js index 95be4765..161de51e 100644 --- a/web/assets/js/bs5.videos.js +++ b/web/assets/js/bs5.videos.js @@ -442,35 +442,93 @@ function loadEventsData(videoEvents){ loadedEventsInMemory[`${anEvent.mid}${anEvent.time}`] = anEvent }) } +function getVideoSearchRequestQueries(options){ + var searchQuery = options.searchQuery + var requestQueries = [] + var monitorId = options.monitorId + var archived = options.archived + var customVideoSet = options.customVideoSet + var limit = options.limit + var eventLimit = options.eventLimit || 300 + var doLimitOnFames = options.doLimitOnFames || false + var eventStartTime + var eventEndTime + if(options.startDate){ + eventStartTime = formattedTimeForFilename(options.startDate,false) + requestQueries.push(`start=${eventStartTime}`) + } + if(options.endDate){ + eventEndTime = formattedTimeForFilename(options.endDate,false) + requestQueries.push(`end=${eventEndTime}`) + } + if(searchQuery){ + requestQueries.push(`search=${searchQuery}`) + } + if(archived){ + requestQueries.push(`archived=1`) + } + return { + searchQuery, + monitorId, + archived, + customVideoSet, + limit, + eventLimit, + doLimitOnFames, + eventStartTime, + eventEndTime, + requestQueries, + } +} +function mergeVideosAndBin(options,callback){ + const { + searchQuery, + monitorId, + archived, + customVideoSet, + limit, + eventLimit, + doLimitOnFames, + eventStartTime, + eventEndTime, + requestQueries, + } = getVideoSearchRequestQueries(options); + const videos = options.videos.map(video => { + const newVideo = { + ke: video.ke, + mid: video.mid, + time: video.time, + end: video.end, + saveDir: video.saveDir, + details: video.details, + }; + delete(newVideo.timelapseFrames) + return newVideo + }); + console.log(videos) + return new Promise((resolve) => { + $.post(`${getApiPrefix(`mergeVideos`)}${monitorId ? `/${monitorId}` : ''}?${requestQueries.concat([limit ? `limit=${limit}` : `noLimit=1`]).join('&')}`, { + videos, + },function(data){ + resolve(data) + }) + }) +} function getVideos(options,callback,noEvents){ return new Promise((resolve,reject) => { options = options ? options : {} - var searchQuery = options.searchQuery - var requestQueries = [] - var monitorId = options.monitorId - var archived = options.archived - var customVideoSet = options.customVideoSet - var limit = options.limit - var eventLimit = options.eventLimit || 300 - var doLimitOnFames = options.doLimitOnFames || false - var eventStartTime - var eventEndTime - // var startDate = options.startDate - // var endDate = options.endDate - if(options.startDate){ - eventStartTime = formattedTimeForFilename(options.startDate,false) - requestQueries.push(`start=${eventStartTime}`) - } - if(options.endDate){ - eventEndTime = formattedTimeForFilename(options.endDate,false) - requestQueries.push(`end=${eventEndTime}`) - } - if(searchQuery){ - requestQueries.push(`search=${searchQuery}`) - } - if(archived){ - requestQueries.push(`archived=1`) - } + const { + searchQuery, + monitorId, + archived, + customVideoSet, + limit, + eventLimit, + doLimitOnFames, + eventStartTime, + eventEndTime, + requestQueries, + } = getVideoSearchRequestQueries(options); $.getJSON(`${getApiPrefix(customVideoSet ? customVideoSet : searchQuery ? `videosByEventTag` : `videos`)}${monitorId ? `/${monitorId}` : ''}?${requestQueries.concat([limit ? `limit=${limit}` : `noLimit=1`]).join('&')}`,function(data){ var videos = data.videos.map((video) => { return Object.assign({},video,{ diff --git a/web/assets/js/bs5.videosTable.js b/web/assets/js/bs5.videosTable.js index 112cb21b..ab36c362 100644 --- a/web/assets/js/bs5.videosTable.js +++ b/web/assets/js/bs5.videosTable.js @@ -274,6 +274,43 @@ $(document).ready(function(e){ var downloadUrl = buildNewFileLink(data) downloadFile(downloadUrl,data.name) } + function mergeSelectedVideos(){ + var videos = getSelectedRows(true) + var dateRange = getSelectedTime(dateSelector); + var searchQuery = objectTagSearchField.val() || null; + var startDate = dateRange.startDate; + var endDate = dateRange.endDate; + var monitorId = monitorsList.val(); + var wantsArchivedVideo = getVideoSetSelected() === 'archive'; + if(!monitorId){ + new PNotify({ + title: lang['No Monitor Selected'], + text: lang['No Monitor Found, Ignoring Request'], + type: 'danger', + }) + return + } + $.confirm.create({ + title: lang["Merge Videos"], + body: `${videos.length > 0 ? lang.MergeAllSelected : lang.MergeAllInRange}`, + clickOptions: { + title: ' ' + lang.Save, + class: 'btn-success btn-sm' + }, + clickCallback: async function(){ + console.log('Merging Video...') + var result = await mergeVideosAndBin({ + monitorId, + startDate, + endDate, + videos, + searchQuery, + archived: wantsArchivedVideo, + }); + console.log('Merged Video! Check Filebin.') + } + }); + } $('body') .on('click','.open-videosTable',function(e){ e.preventDefault() @@ -307,6 +344,11 @@ $(document).ready(function(e){ zipVideosAndDownloadWithConfirm(videos) return false; }) + .on('click','.merge-selected-videos',function(e){ + e.preventDefault() + mergeSelectedVideos(); + return false; + }) .on('click','.refresh-data',function(e){ e.preventDefault() drawVideosTableViewElements() @@ -410,6 +452,14 @@ $(document).ready(function(e){ }) onWebSocketEvent((data) => { switch(data.f){ + case'fileBin_item_added': + new PNotify({ + title: lang['File Saved'], + text: `${lang.checkFileBinForNewFile}

${lang.Download}`, + type: 'success', + sticky: true, + }) + break; case'video_delete': case'video_delete_cloud': if(tabTree.name === 'videosTableView'){ diff --git a/web/pages/blocks/home/videosTable.ejs b/web/pages/blocks/home/videosTable.ejs index 549062cc..8524836d 100644 --- a/web/pages/blocks/home/videosTable.ejs +++ b/web/pages/blocks/home/videosTable.ejs @@ -21,6 +21,7 @@
  • <%- lang.Unarchive %>
  • <%- lang.Compress %>
  • <%- lang['Zip and Download'] %>
  • +
  • <%- lang['Merge'] %>
  • <%- lang.Delete %>