mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2024-12-26 23:25:55 -05:00
b78ffc3808
The time from recorded to viewable was previously 60-120 sec for the first recording of a RTSP session, 0-60 sec otherwise. Now it's one frame.
432 lines
15 KiB
JavaScript
432 lines
15 KiB
JavaScript
// vim: set et sw=2:
|
|
|
|
// TODO: test abort.
|
|
// TODO: add error bar on fetch failure.
|
|
// TODO: style: no globals? string literals? line length? fn comments?
|
|
// TODO: live updating.
|
|
|
|
import 'jquery-ui/themes/base/button.css';
|
|
import 'jquery-ui/themes/base/core.css';
|
|
import 'jquery-ui/themes/base/datepicker.css';
|
|
import 'jquery-ui/themes/base/dialog.css';
|
|
import 'jquery-ui/themes/base/resizable.css';
|
|
import 'jquery-ui/themes/base/theme.css';
|
|
import 'jquery-ui/themes/base/tooltip.css';
|
|
|
|
import $ from 'jquery';
|
|
import 'jquery-ui/ui/widgets/datepicker';
|
|
import 'jquery-ui/ui/widgets/dialog';
|
|
import 'jquery-ui/ui/widgets/tooltip';
|
|
import moment from 'moment-timezone';
|
|
|
|
const apiUrl = '/api/';
|
|
|
|
const allStreamTypes = ['main', 'sub'];
|
|
|
|
// IANA timezone name.
|
|
let zone = null;
|
|
|
|
// A dict describing the currently selected range.
|
|
let selectedRange = {
|
|
startDateStr: null, // null or YYYY-MM-DD
|
|
startTimeStr: '', // empty or HH:mm[:ss[:FFFFF]][+-HHmm]
|
|
startTime90k: null, // null or 90k units since epoch
|
|
endDateStr: null, // null or YYYY-MM-DD
|
|
endTimeStr: '', // empty or HH:mm[:ss[:FFFFF]][+-HHmm]
|
|
endTime90k: null, // null or 90k units since epoch
|
|
singleDateStr: null, // if startDateStr===endDateStr, that value, otherwise null
|
|
};
|
|
|
|
// Cameras is a dictionary as retrieved from apiUrl + some extra props within
|
|
// the streams dicts:
|
|
// * "enabled" is a boolean indicating if the camera should be displayed and
|
|
// if it should be used to constrain the datepickers.
|
|
// * "recordingsUrl" is null or the currently fetched/fetching .../recordings url.
|
|
// * "recordingsRange" is a null or a dict (in the same format as
|
|
// selectedRange) describing what is fetching/fetched.
|
|
// * "recordingsData" is null or the data fetched from "recordingsUrl".
|
|
// * "recordingsReq" is null or a jQuery ajax object of an active .../recordings
|
|
// request if currently fetching.
|
|
let cameras = null;
|
|
|
|
function req(url) {
|
|
return $.ajax(url, {
|
|
dataType: 'json',
|
|
headers: {'Accept': 'application/json'},
|
|
});
|
|
}
|
|
|
|
// Produces a human-readable but unambiguous format of the given timestamp:
|
|
// ISO-8601 plus an extra colon component after the seconds indicating the
|
|
// fractional time in 90,000ths of a second.
|
|
function formatTime(ts90k) {
|
|
const m = moment.tz(ts90k / 90, zone);
|
|
const frac = ts90k % 90000;
|
|
return m.format('YYYY-MM-DDTHH:mm:ss:' + String(100000 + frac).substr(1) + 'Z');
|
|
}
|
|
|
|
function onSelectVideo(camera, streamType, range, recording) {
|
|
let url = apiUrl + 'cameras/' + camera.uuid + '/' + streamType + '/view.mp4?s=' + recording.startId;
|
|
if (recording.endId !== undefined) {
|
|
url += '-' + recording.endId;
|
|
}
|
|
if (recording.firstUncommitted !== undefined) {
|
|
url += '@' + recording.openId; // disambiguate.
|
|
}
|
|
const trim = $("#trim").prop("checked");
|
|
let rel = '';
|
|
let startTime90k = recording.startTime90k;
|
|
if (trim && recording.startTime90k < range.startTime90k) {
|
|
rel += range.startTime90k - recording.startTime90k;
|
|
startTime90k = range.startTime90k;
|
|
}
|
|
rel += '-';
|
|
let endTime90k = recording.endTime90k;
|
|
if (trim && recording.endTime90k > range.endTime90k) {
|
|
rel += range.endTime90k - recording.startTime90k;
|
|
endTime90k = range.endTime90k;
|
|
} else if (recording.growing !== undefined) {
|
|
// View just the portion described here.
|
|
rel += recording.endTime90k - recording.startTime90k;
|
|
}
|
|
if (rel !== '-') {
|
|
url += '.' + rel;
|
|
}
|
|
if ($("#ts").prop("checked")) {
|
|
url += '&ts=true';
|
|
}
|
|
console.log('Displaying video: ', url);
|
|
let video = $('<video controls preload="auto" autoplay="true"/>');
|
|
let dialog = $('<div class="playdialog"/>').append(video);
|
|
$("body").append(dialog);
|
|
|
|
// Format start and end times for the dialog title. If they're the same day,
|
|
// abbreviate the end time.
|
|
let formattedStart = formatTime(startTime90k);
|
|
let formattedEnd = formatTime(endTime90k);
|
|
let timePos = 'YYYY-mm-ddT'.length;
|
|
if (formattedEnd.startsWith(formattedStart.substr(0, timePos))) {
|
|
formattedEnd = formattedEnd.substr(timePos);
|
|
}
|
|
dialog.dialog({
|
|
title: camera.shortName + " " + streamType + ", " + formattedStart + " to " + formattedEnd,
|
|
width: recording.videoSampleEntryWidth / 4,
|
|
close: function() { dialog.remove(); },
|
|
});
|
|
video.attr("src", url);
|
|
}
|
|
|
|
function formatRecordings(camera, streamType) {
|
|
let tbody = $("#tab-" + camera.uuid + "-" + streamType);
|
|
$(".loading", tbody).hide();
|
|
$(".r", tbody).remove();
|
|
const frameRateFmt = new Intl.NumberFormat([], {maximumFractionDigits: 0});
|
|
const sizeFmt = new Intl.NumberFormat([], {maximumFractionDigits: 1});
|
|
const trim = $("#trim").prop("checked");
|
|
const stream = camera.streams[streamType];
|
|
for (const recording of stream.recordingsData.recordings) {
|
|
const duration = (recording.endTime90k - recording.startTime90k) / 90000;
|
|
let row = $('<tr class="r"/>');
|
|
const startTime90k = trim && recording.startTime90k < stream.recordingsRange.startTime90k
|
|
? stream.recordingsRange.startTime90k : recording.startTime90k;
|
|
const endTime90k = trim && recording.endTime90k > stream.recordingsRange.endTime90k
|
|
? stream.recordingsRange.endTime90k : recording.endTime90k;
|
|
let formattedStart = formatTime(startTime90k);
|
|
let formattedEnd = formatTime(endTime90k);
|
|
const singleDateStr = stream.recordingsRange.singleDateStr;
|
|
if (singleDateStr !== null && formattedStart.startsWith(singleDateStr)) {
|
|
formattedStart = formattedStart.substr(11);
|
|
}
|
|
if (singleDateStr !== null && formattedEnd.startsWith(singleDateStr)) {
|
|
formattedEnd = formattedEnd.substr(11);
|
|
}
|
|
row.append(
|
|
$("<td/>").text(formattedStart),
|
|
$("<td/>").text(formattedEnd),
|
|
$("<td/>").text(recording.videoSampleEntryWidth + "x" + recording.videoSampleEntryHeight),
|
|
$("<td/>").text(frameRateFmt.format(recording.videoSamples / duration)),
|
|
$("<td/>").text(sizeFmt.format(recording.sampleFileBytes / 1048576) + " MB"),
|
|
$("<td/>").text(sizeFmt.format(recording.sampleFileBytes / duration * .000008) + " Mbps"));
|
|
row.on("click", function() { onSelectVideo(camera, streamType, stream.recordingsRange, recording); });
|
|
tbody.append(row);
|
|
}
|
|
};
|
|
|
|
function reselectDateRange(startDateStr, endDateStr) {
|
|
selectedRange.startDateStr = startDateStr;
|
|
selectedRange.endDateStr = endDateStr;
|
|
selectedRange.startTime90k = parseDateTime(startDateStr, selectedRange.startTimeStr, false);
|
|
selectedRange.endTime90k = parseDateTime(endDateStr, selectedRange.endTimeStr, true);
|
|
fetch();
|
|
}
|
|
|
|
// Run when selectedRange is populated/changed or when split changes.
|
|
function fetch() {
|
|
console.log('Fetching ', formatTime(selectedRange.startTime90k), ' to ',
|
|
formatTime(selectedRange.endTime90k));
|
|
let split = $("#split").val();
|
|
for (let camera of cameras) {
|
|
for (const streamType in camera.streams) {
|
|
let stream = camera.streams[streamType];
|
|
let url = apiUrl + 'cameras/' + camera.uuid + '/' + streamType + '/recordings?startTime90k=' +
|
|
selectedRange.startTime90k + '&endTime90k=' + selectedRange.endTime90k;
|
|
if (split !== '') {
|
|
url += '&split90k=' + split;
|
|
}
|
|
if (url === stream.recordingsUrl) {
|
|
continue; // nothing to do.
|
|
}
|
|
console.log('url: ', url);
|
|
if (stream.recordingsReq !== null) {
|
|
stream.recordingsReq.abort();
|
|
}
|
|
let tbody = $("#tab-" + camera.uuid + "-" + streamType);
|
|
$(".r", tbody).remove();
|
|
$(".loading", tbody).show();
|
|
let r = req(url);
|
|
stream.recordingsUrl = url;
|
|
stream.recordingsRange = selectedRange;
|
|
stream.recordingsReq = r;
|
|
r.always(function() { stream.recordingsReq = null; });
|
|
r.then(function(data, status, req) {
|
|
// Sort recordings in descending order.
|
|
data.recordings.sort(function(a, b) { return b.startId - a.startId; });
|
|
stream.recordingsData = data;
|
|
formatRecordings(camera, streamType);
|
|
}).catch(function(data, status, err) {
|
|
console.log(url, ' load failed: ', status, ': ', err);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run initially and when changing camera filter.
|
|
function setupCalendar() {
|
|
let merged = {};
|
|
for (const camera of cameras) {
|
|
for (const streamType in camera.streams) {
|
|
const stream = camera.streams[streamType];
|
|
if (!stream.enabled) {
|
|
continue;
|
|
}
|
|
for (const dateStr in stream.days) {
|
|
merged[dateStr] = true;
|
|
}
|
|
}
|
|
}
|
|
let minDateStr = '9999-99-99';
|
|
let maxDateStr = '0000-00-00';
|
|
for (const dateStr in merged) {
|
|
if (dateStr > maxDateStr) {
|
|
maxDateStr = dateStr;
|
|
}
|
|
if (dateStr < minDateStr) {
|
|
minDateStr = dateStr;
|
|
}
|
|
}
|
|
let from = $("#start-date");
|
|
let to = $("#end-date");
|
|
let beforeShowDay = function(date) {
|
|
let dateStr = date.toISOString().substr(0, 10);
|
|
return [dateStr in merged, "", ""];
|
|
}
|
|
if ($("#end-date-same").prop("checked")) {
|
|
from.datepicker("option", {
|
|
dateFormat: $.datepicker.ISO_8601,
|
|
minDate: minDateStr,
|
|
maxDate: maxDateStr,
|
|
onSelect: function(dateStr, picker) {
|
|
reselectDateRange(dateStr, dateStr);
|
|
},
|
|
beforeShowDay: beforeShowDay,
|
|
disabled: false,
|
|
});
|
|
to.datepicker("destroy");
|
|
to.datepicker({disabled: true});
|
|
} else {
|
|
from.datepicker("option", {
|
|
dateFormat: $.datepicker.ISO_8601,
|
|
minDate: minDateStr,
|
|
onSelect: function(dateStr, picker) {
|
|
to.datepicker("option", "minDate", from.datepicker("getDate").toISOString().substr(0, 10));
|
|
reselectDateRange(dateStr, to.datepicker("getDate").toISOString().substr(0, 10));
|
|
},
|
|
beforeShowDay: beforeShowDay,
|
|
disabled: false,
|
|
});
|
|
to.datepicker("option", {
|
|
dateFormat: $.datepicker.ISO_8601,
|
|
minDate: from.datepicker("getDate"),
|
|
maxDate: maxDateStr,
|
|
onSelect: function(dateStr, picker) {
|
|
from.datepicker("option", "maxDate", to.datepicker("getDate").toISOString().substr(0, 10));
|
|
reselectDateRange(from.datepicker("getDate").toISOString().substr(0, 10), dateStr);
|
|
},
|
|
beforeShowDay: beforeShowDay,
|
|
disabled: false,
|
|
});
|
|
to.datepicker("setDate", from.datepicker("getDate"));
|
|
from.datepicker("option", {maxDate: to.datepicker("getDate")});
|
|
}
|
|
const date = from.datepicker("getDate");
|
|
if (date !== null) {
|
|
const dateStr = date.toISOString().substr(0, 10);
|
|
reselectDateRange(dateStr, dateStr);
|
|
}
|
|
};
|
|
|
|
function onStreamChange(event, camera, streamType) {
|
|
let stream = camera.streams[streamType];
|
|
stream.enabled = event.target.checked;
|
|
let id = "#tab-" + camera.uuid + "-" + streamType;
|
|
if (stream.enabled) {
|
|
$(id).show();
|
|
} else {
|
|
$(id).hide();
|
|
}
|
|
console.log(camera.shortName + "/" + streamType, stream.enabled ? 'enabled' : 'disabled');
|
|
setupCalendar();
|
|
}
|
|
|
|
// Parses the given date and time string into a valid time90k or null.
|
|
function parseDateTime(dateStr, timeStr, isEnd) {
|
|
// Match HH:mm[:ss[:FFFFF]][+-HH:mm]
|
|
// Group 1 is the hour and minute (HH:mm).
|
|
// Group 2 is the seconds (:ss), if any.
|
|
// Group 3 is the fraction (FFFFF), if any.
|
|
// Group 4 is the zone (+-HH:mm), if any.
|
|
const timeRe =
|
|
/^([0-9]{1,2}:[0-9]{2})(?:(:[0-9]{2})(?::([0-9]{5}))?)?([+-][0-9]{1,2}:?(?:[0-9]{2})?)?$/;
|
|
|
|
if (timeStr === '') {
|
|
const m = moment.tz(dateStr, zone);
|
|
if (isEnd) {
|
|
m.add({days: 1});
|
|
}
|
|
return m.valueOf() * 90;
|
|
}
|
|
|
|
const match = timeRe.exec(timeStr);
|
|
if (match === null) {
|
|
return null;
|
|
}
|
|
|
|
const orBlank = function(s) { return s === undefined ? "" : s; };
|
|
const datetimeStr = dateStr + 'T' + match[1] + orBlank(match[2]) + orBlank(match[4]);
|
|
const m = moment.tz(datetimeStr, zone);
|
|
if (!m.isValid()) {
|
|
return null;
|
|
}
|
|
|
|
const frac = match[3] === undefined ? 0 : parseInt(match[3], 10);
|
|
return m.valueOf() * 90 + frac;
|
|
}
|
|
|
|
function onTimeChange(e, isEnd) {
|
|
let parsed = parseDateTime(isEnd ? selectedRange.endDateStr : selectedRange.startDateStr,
|
|
e.target.value, isEnd);
|
|
if (parsed == null) {
|
|
console.log('bad time change');
|
|
$(e.target).addClass('ui-state-error');
|
|
return;
|
|
}
|
|
$(e.target).removeClass('ui-state-error');
|
|
console.log(isEnd ? "end" : "start", ' time change to: ', parsed, ' (', formatTime(parsed), ')');
|
|
if (isEnd) {
|
|
selectedRange.endTimeStr = e.target.value;
|
|
selectedRange.endTime90k = parsed;
|
|
} else {
|
|
selectedRange.startTimeStr = e.target.value;
|
|
selectedRange.startTime90k = parsed;
|
|
}
|
|
fetch();
|
|
}
|
|
|
|
function onReceivedCameras(data) {
|
|
let camtable = $("#cameras");
|
|
if (data.cameras.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Add a header row.
|
|
let hdr = $('<tr/>').append($('<th/>'));
|
|
for (const streamType of allStreamTypes) {
|
|
hdr.append($('<th/>').text(streamType));
|
|
}
|
|
camtable.append(hdr);
|
|
|
|
var reqs = [];
|
|
let videos = $("#videos");
|
|
for (let camera of data.cameras) {
|
|
let row = $('<tr/>').append($('<td>').text(camera.shortName));
|
|
let anyCheckedForCam = false;
|
|
for (const streamType of allStreamTypes) {
|
|
let stream = camera.streams[streamType];
|
|
if (stream === undefined) {
|
|
row.append('<td/>');
|
|
continue;
|
|
}
|
|
const id = "cam-" + camera.uuid + "-" + streamType;
|
|
let checkBox = $('<input type="checkbox">').attr("name", id).attr("id", id);
|
|
checkBox.change(function(event) { onStreamChange(event, camera, streamType); });
|
|
row.append($("<td/>").append(checkBox));
|
|
let tab = $("<tbody>").attr("id", "tab-" + camera.uuid + "-" + streamType);
|
|
tab.append(
|
|
$('<tr class="name">').append($('<th colspan=6/>').text(camera.shortName + " " + streamType)),
|
|
$('<tr class="hdr"><th>start</th><th>end</th><th>resolution</th><th>fps</th><th>size</th><th>bitrate</th></tr>'),
|
|
$('<tr class="loading"><td colspan=6>loading...</td></tr>'));
|
|
videos.append(tab);
|
|
stream.recordingsUrl = null;
|
|
stream.recordingsRange = null;
|
|
stream.recordingsData = null;
|
|
stream.recordingsReq = null;
|
|
stream.enabled = false;
|
|
if (!anyCheckedForCam) {
|
|
checkBox.attr("checked", "checked");
|
|
anyCheckedForCam = true;
|
|
stream.enabled = true;
|
|
} else {
|
|
tab.hide();
|
|
}
|
|
}
|
|
camtable.append(row);
|
|
}
|
|
$("#end-date-same").change(function(e) { setupCalendar(); });
|
|
$("#end-date-other").change(function(e) { setupCalendar(); });
|
|
$("#start-date").datepicker({disabled: true});
|
|
$("#end-date").datepicker({disabled: true});
|
|
$("#start-time").change(function(e) { onTimeChange(e, false); });
|
|
$("#end-time").change(function(e) { onTimeChange(e, true); });
|
|
$("#split").change(function(e) {
|
|
if (selectedRange.startTime90k !== null) {
|
|
fetch();
|
|
}
|
|
});
|
|
$("#trim").change(function(e) {
|
|
// Changing the trim doesn't need to refetch data, but it does need to
|
|
// reformat the tables.
|
|
let newTrim = e.target.checked;
|
|
for (camera of cameras) {
|
|
for (streamType in camera.streams) {
|
|
const stream = camera.streams[streamType];
|
|
if (stream.recordingsData !== null) {
|
|
formatRecordings(camera, streamType);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
zone = data.timeZoneName;
|
|
cameras = data.cameras;
|
|
console.log('Loaded cameras.');
|
|
setupCalendar();
|
|
}
|
|
|
|
$(function() {
|
|
$(document).tooltip();
|
|
req(apiUrl + '?days=true').then(function(data, status, req) {
|
|
onReceivedCameras(data);
|
|
}).catch(function(data, status, err) {
|
|
console.log('cameras load failed: ', status, err);
|
|
});
|
|
});
|