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 @@