mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2024-12-26 23:25:55 -05:00
Web interface to the new SQLite schema.
This is almost certain to have performance problems with large databases, but it's a useful starting point. No tests yet. It shouldn't be too hard to add some for moonfire-db.h, but I'm impatient to fake up enough data to check on the performance and see what needs to change there first.
This commit is contained in:
parent
055883d248
commit
40cd983355
@ -49,6 +49,7 @@ set(MOONFIRE_NVR_SRCS
|
||||
filesystem.cc
|
||||
h264.cc
|
||||
http.cc
|
||||
moonfire-db.cc
|
||||
moonfire-nvr.cc
|
||||
mp4.cc
|
||||
profiler.cc
|
||||
@ -56,7 +57,8 @@ set(MOONFIRE_NVR_SRCS
|
||||
sqlite.cc
|
||||
string.cc
|
||||
time.cc
|
||||
uuid.cc)
|
||||
uuid.cc
|
||||
web.cc)
|
||||
|
||||
add_library(moonfire-nvr-lib ${MOONFIRE_NVR_SRCS} ${PROTO_SRCS} ${PROTO_HDRS})
|
||||
target_link_libraries(moonfire-nvr-lib ${MOONFIRE_DEPS})
|
||||
|
@ -62,7 +62,7 @@ class QueryParameters {
|
||||
// Caller should check ok() afterward.
|
||||
QueryParameters(const char *uri) {
|
||||
TAILQ_INIT(&me_);
|
||||
ok_ = evhttp_parse_query_str(uri, &me_) == 0;
|
||||
ok_ = evhttp_parse_query(uri, &me_) == 0;
|
||||
}
|
||||
QueryParameters(const QueryParameters &) = delete;
|
||||
void operator=(const QueryParameters &) = delete;
|
||||
|
342
src/moonfire-db.cc
Normal file
342
src/moonfire-db.cc
Normal file
@ -0,0 +1,342 @@
|
||||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2016 Scott Lamb <slamb@slamb.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// In addition, as a special exception, the copyright holders give
|
||||
// permission to link the code of portions of this program with the
|
||||
// OpenSSL library under certain conditions as described in each
|
||||
// individual source file, and distribute linked combinations including
|
||||
// the two.
|
||||
//
|
||||
// You must obey the GNU General Public License in all respects for all
|
||||
// of the code used other than OpenSSL. If you modify file(s) with this
|
||||
// exception, you may extend this exception to your version of the
|
||||
// file(s), but you are not obligated to do so. If you do not wish to do
|
||||
// so, delete this exception statement from your version. If you delete
|
||||
// this exception statement from all source files in the program, then
|
||||
// also delete it here.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// moonfire-db.cc: implementation of moonfire-db.h interface.
|
||||
|
||||
#include "moonfire-db.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <glog/logging.h>
|
||||
|
||||
#include "http.h"
|
||||
#include "mp4.h"
|
||||
#include "recording.h"
|
||||
|
||||
namespace moonfire_nvr {
|
||||
|
||||
bool MoonfireDatabase::Init(std::string *error_message) {
|
||||
list_cameras_query_ = db_->Prepare(
|
||||
R"(
|
||||
select
|
||||
camera.id,
|
||||
camera.uuid,
|
||||
camera.short_name,
|
||||
camera.description,
|
||||
camera.retain_bytes,
|
||||
min(recording.start_time_90k),
|
||||
max(recording.end_time_90k),
|
||||
sum(recording.end_time_90k - recording.start_time_90k),
|
||||
sum(recording.sample_file_bytes)
|
||||
from
|
||||
camera
|
||||
left join recording on
|
||||
(camera.id = recording.camera_id and
|
||||
recording.status = 1)
|
||||
group by
|
||||
camera.id,
|
||||
camera.uuid,
|
||||
camera.short_name,
|
||||
camera.description,
|
||||
camera.retain_bytes;
|
||||
)",
|
||||
nullptr, error_message);
|
||||
if (!list_cameras_query_.valid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
get_camera_query_ = db_->Prepare(
|
||||
R"(
|
||||
select
|
||||
uuid,
|
||||
short_name,
|
||||
description,
|
||||
retain_bytes
|
||||
from
|
||||
camera
|
||||
where
|
||||
id = :camera_id;)",
|
||||
nullptr, error_message);
|
||||
if (!get_camera_query_.valid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list_camera_recordings_query_ = db_->Prepare(
|
||||
R"(
|
||||
select
|
||||
recording.start_time_90k,
|
||||
recording.end_time_90k,
|
||||
recording.video_samples,
|
||||
recording.sample_file_bytes,
|
||||
recording.video_sample_entry_sha1,
|
||||
video_sample_entry.width,
|
||||
video_sample_entry.height
|
||||
from
|
||||
recording
|
||||
join video_sample_entry on
|
||||
(recording.video_sample_entry_sha1 = video_sample_entry.sha1)
|
||||
where
|
||||
recording.status = 1 and
|
||||
camera_id = :camera_id
|
||||
order by
|
||||
recording.start_time_90k;)",
|
||||
nullptr, error_message);
|
||||
if (!list_camera_recordings_query_.valid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
build_mp4_query_ = db_->Prepare(
|
||||
R"(
|
||||
select
|
||||
recording.rowid,
|
||||
recording.start_time_90k,
|
||||
recording.end_time_90k,
|
||||
recording.sample_file_bytes,
|
||||
recording.sample_file_uuid,
|
||||
recording.sample_file_sha1,
|
||||
recording.video_sample_entry_sha1,
|
||||
recording.video_index,
|
||||
recording.video_samples,
|
||||
recording.video_sync_samples,
|
||||
video_sample_entry.bytes,
|
||||
video_sample_entry.width,
|
||||
video_sample_entry.height
|
||||
from
|
||||
recording join video_sample_entry on
|
||||
(recording.video_sample_entry_sha1 = video_sample_entry.sha1)
|
||||
where
|
||||
recording.status = 1 and
|
||||
camera_id = :camera_id and
|
||||
recording.start_time_90k < :end_time_90k and
|
||||
recording.end_time_90k > :start_time_90k
|
||||
order by
|
||||
recording.start_time_90k;)",
|
||||
nullptr, error_message);
|
||||
if (!build_mp4_query_.valid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MoonfireDatabase::ListCameras(
|
||||
std::function<IterationControl(const ListCamerasRow &)> cb,
|
||||
std::string *error_message) {
|
||||
DatabaseContext ctx(db_);
|
||||
auto run = ctx.Borrow(&list_cameras_query_);
|
||||
ListCamerasRow row;
|
||||
while (run.Step() == SQLITE_ROW) {
|
||||
row.id = run.ColumnInt64(0);
|
||||
if (!row.uuid.ParseBinary(run.ColumnBlob(1))) {
|
||||
*error_message = StrCat("invalid uuid in row id ", row.id);
|
||||
return false;
|
||||
}
|
||||
row.short_name = run.ColumnText(2).as_string();
|
||||
row.description = run.ColumnText(3).as_string();
|
||||
row.retain_bytes = run.ColumnInt64(4);
|
||||
row.min_recording_start_time_90k = run.ColumnInt64(5);
|
||||
row.max_recording_end_time_90k = run.ColumnInt64(6);
|
||||
row.total_recording_duration_90k = run.ColumnInt64(7);
|
||||
row.total_sample_file_bytes = run.ColumnInt64(8);
|
||||
if (cb(row) == IterationControl::kBreak) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (run.status() != SQLITE_DONE) {
|
||||
*error_message = StrCat("sqlite query failed: ", run.error_message());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MoonfireDatabase::GetCamera(int64_t camera_id, GetCameraRow *row,
|
||||
std::string *error_message) {
|
||||
DatabaseContext ctx(db_);
|
||||
auto run = ctx.Borrow(&get_camera_query_);
|
||||
run.BindInt64(":camera_id", camera_id);
|
||||
if (run.Step() == SQLITE_ROW) {
|
||||
if (!row->uuid.ParseBinary(run.ColumnBlob(0))) {
|
||||
*error_message =
|
||||
StrCat("unable to parse uuid ", ToHex(run.ColumnBlob(0)));
|
||||
return false;
|
||||
}
|
||||
row->short_name = run.ColumnText(1).as_string();
|
||||
row->description = run.ColumnText(2).as_string();
|
||||
row->retain_bytes = run.ColumnInt64(3);
|
||||
} else if (run.status() == SQLITE_DONE) {
|
||||
*error_message = "no such camera";
|
||||
return false;
|
||||
}
|
||||
if (run.Step() == SQLITE_ROW) {
|
||||
*error_message = "multiple rows returned unexpectedly";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MoonfireDatabase::ListCameraRecordings(
|
||||
int64_t camera_id,
|
||||
std::function<IterationControl(const ListCameraRecordingsRow &)> cb,
|
||||
std::string *error_message) {
|
||||
DatabaseContext ctx(db_);
|
||||
auto run = ctx.Borrow(&list_camera_recordings_query_);
|
||||
run.BindInt64(":camera_id", camera_id);
|
||||
ListCameraRecordingsRow row;
|
||||
while (run.Step() == SQLITE_ROW) {
|
||||
row.start_time_90k = run.ColumnInt64(0);
|
||||
row.end_time_90k = run.ColumnInt64(1);
|
||||
row.video_samples = run.ColumnInt64(2);
|
||||
row.sample_file_bytes = run.ColumnInt64(3);
|
||||
auto video_sample_entry_sha1 = run.ColumnBlob(4);
|
||||
row.video_sample_entry_sha1.assign(video_sample_entry_sha1.data(),
|
||||
video_sample_entry_sha1.size());
|
||||
row.width = run.ColumnInt64(5);
|
||||
row.height = run.ColumnInt64(6);
|
||||
if (cb(row) == IterationControl::kBreak) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (run.status() != SQLITE_DONE) {
|
||||
*error_message = StrCat("sqlite query failed: ", run.error_message());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::shared_ptr<VirtualFile> MoonfireDatabase::BuildMp4(
|
||||
int64_t camera_id, int64_t start_time_90k, int64_t end_time_90k,
|
||||
std::string *error_message) {
|
||||
LOG(INFO) << "Building mp4 for camera: " << camera_id
|
||||
<< ", start_time_90k: " << start_time_90k
|
||||
<< ", end_time_90k: " << end_time_90k;
|
||||
|
||||
Mp4FileBuilder builder;
|
||||
int64_t next_row_start_time_90k = start_time_90k;
|
||||
VideoSampleEntry sample_entry;
|
||||
int64_t rows = 0;
|
||||
{
|
||||
VLOG(1) << "...(1/4): Waiting for database lock";
|
||||
DatabaseContext ctx(db_);
|
||||
VLOG(1) << "...(2/4): Querying database";
|
||||
auto run = ctx.Borrow(&build_mp4_query_);
|
||||
run.BindInt64(":camera_id", camera_id);
|
||||
run.BindInt64(":end_time_90k", end_time_90k);
|
||||
run.BindInt64(":start_time_90k", start_time_90k);
|
||||
Recording recording;
|
||||
while (run.Step() == SQLITE_ROW) {
|
||||
recording.rowid = run.ColumnInt64(0);
|
||||
VLOG(2) << "row: " << recording.rowid;
|
||||
recording.start_time_90k = run.ColumnInt64(1);
|
||||
recording.end_time_90k = run.ColumnInt64(2);
|
||||
recording.sample_file_bytes = run.ColumnInt64(3);
|
||||
if (!recording.sample_file_uuid.ParseBinary(run.ColumnBlob(4))) {
|
||||
*error_message =
|
||||
StrCat("recording ", recording.rowid, " has unparseable uuid ",
|
||||
ToHex(run.ColumnBlob(4)));
|
||||
return false;
|
||||
}
|
||||
recording.sample_file_path =
|
||||
StrCat("/home/slamb/new-moonfire/sample/",
|
||||
recording.sample_file_uuid.UnparseText());
|
||||
recording.sample_file_sha1 = run.ColumnBlob(5).as_string();
|
||||
recording.video_sample_entry_sha1 = run.ColumnBlob(6).as_string();
|
||||
recording.video_index = run.ColumnBlob(7).as_string();
|
||||
recording.video_samples = run.ColumnInt64(8);
|
||||
recording.video_sync_samples = run.ColumnInt64(9);
|
||||
|
||||
if (rows == 0 && recording.start_time_90k != next_row_start_time_90k) {
|
||||
*error_message =
|
||||
StrCat("recording starts late: ",
|
||||
PrettyTimestamp(recording.start_time_90k), " (",
|
||||
recording.start_time_90k, ") rather than requested: ",
|
||||
PrettyTimestamp(start_time_90k), " (", start_time_90k, ")");
|
||||
return false;
|
||||
} else if (recording.start_time_90k != next_row_start_time_90k) {
|
||||
*error_message =
|
||||
StrCat("gap/overlap in recording: ",
|
||||
PrettyTimestamp(next_row_start_time_90k), " (",
|
||||
next_row_start_time_90k, ") to: ",
|
||||
PrettyTimestamp(recording.start_time_90k), " (",
|
||||
recording.start_time_90k, ") before row ", rows);
|
||||
return false;
|
||||
}
|
||||
|
||||
next_row_start_time_90k = recording.end_time_90k;
|
||||
|
||||
if (rows > 0 && recording.video_sample_entry_sha1 != sample_entry.sha1) {
|
||||
*error_message =
|
||||
StrCat("inconsistent video sample entries: this recording has ",
|
||||
ToHex(recording.video_sample_entry_sha1), ", previous had ",
|
||||
ToHex(sample_entry.sha1));
|
||||
return false;
|
||||
} else if (rows == 0) {
|
||||
sample_entry.sha1 = run.ColumnBlob(6).as_string();
|
||||
sample_entry.data = run.ColumnBlob(10).as_string();
|
||||
sample_entry.width = run.ColumnInt64(11);
|
||||
sample_entry.height = run.ColumnInt64(12);
|
||||
builder.SetSampleEntry(sample_entry);
|
||||
}
|
||||
|
||||
// TODO: correct bounds within recording.
|
||||
// Currently this can return too much data.
|
||||
builder.Append(std::move(recording), 0,
|
||||
std::numeric_limits<int32_t>::max());
|
||||
++rows;
|
||||
}
|
||||
if (run.status() != SQLITE_DONE) {
|
||||
*error_message = StrCat("sqlite query failed: ", run.error_message());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (rows == 0) {
|
||||
*error_message = StrCat("no recordings in range");
|
||||
return false;
|
||||
}
|
||||
if (next_row_start_time_90k != end_time_90k) {
|
||||
*error_message = StrCat("recording ends early: ",
|
||||
PrettyTimestamp(next_row_start_time_90k), " (",
|
||||
next_row_start_time_90k, "), not requested: ",
|
||||
PrettyTimestamp(end_time_90k), " (", end_time_90k,
|
||||
") after ", rows, " rows");
|
||||
return false;
|
||||
}
|
||||
|
||||
VLOG(1) << "...(3/4) building VirtualFile from " << rows << " recordings.";
|
||||
auto file = builder.Build(error_message);
|
||||
if (file == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
VLOG(1) << "...(4/4) success, " << file->size() << " bytes, etag "
|
||||
<< file->etag();
|
||||
return file;
|
||||
}
|
||||
|
||||
} // namespace moonfire_nvr
|
141
src/moonfire-db.h
Normal file
141
src/moonfire-db.h
Normal file
@ -0,0 +1,141 @@
|
||||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2016 Scott Lamb <slamb@slamb.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// In addition, as a special exception, the copyright holders give
|
||||
// permission to link the code of portions of this program with the
|
||||
// OpenSSL library under certain conditions as described in each
|
||||
// individual source file, and distribute linked combinations including
|
||||
// the two.
|
||||
//
|
||||
// You must obey the GNU General Public License in all respects for all
|
||||
// of the code used other than OpenSSL. If you modify file(s) with this
|
||||
// exception, you may extend this exception to your version of the
|
||||
// file(s), but you are not obligated to do so. If you do not wish to do
|
||||
// so, delete this exception statement from your version. If you delete
|
||||
// this exception statement from all source files in the program, then
|
||||
// also delete it here.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// moonfire-db.h: database access logic for the Moonfire NVR SQLite schema.
|
||||
// Currently focused on stuff needed by WebInterface to build a HTML or JSON
|
||||
// interface.
|
||||
//
|
||||
// Performance note: camera-level operations do a sequential scan through
|
||||
// essentially the entire database. This is unacceptable for full-sized
|
||||
// databases; it will have to be measured and improved. Ideas:
|
||||
//
|
||||
// * separate the video index blob from the rest of the recording row,
|
||||
// as it's expected to be 10X-100X larger than everything else and not
|
||||
// necessary for these operations.
|
||||
// * paged results + SQL indexes (but this may only help so much, as it'd be
|
||||
// useful to at least see what days have recordings in one go).
|
||||
// * keep aggregates, either in-memory or as denormalized data in the camera
|
||||
// table. Likely integrating with the recording system, although triggers
|
||||
// may also be possible.
|
||||
|
||||
#ifndef MOONFIRE_NVR_MOONFIRE_DB_H
|
||||
#define MOONFIRE_NVR_MOONFIRE_DB_H
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "common.h"
|
||||
#include "http.h"
|
||||
#include "mp4.h"
|
||||
#include "sqlite.h"
|
||||
#include "uuid.h"
|
||||
|
||||
namespace moonfire_nvr {
|
||||
|
||||
// For use with MoonfireDatabase::ListCameras.
|
||||
struct ListCamerasRow {
|
||||
int64_t id = -1;
|
||||
Uuid uuid;
|
||||
std::string short_name;
|
||||
std::string description;
|
||||
int64_t retain_bytes = -1;
|
||||
|
||||
// Aggregates summarizing completed (status=1) recordings.
|
||||
int64_t min_recording_start_time_90k = -1;
|
||||
int64_t max_recording_end_time_90k = -1;
|
||||
int64_t total_recording_duration_90k = -1;
|
||||
int64_t total_sample_file_bytes = -1;
|
||||
};
|
||||
|
||||
// For use with MoonfireDatabase::GetCamera.
|
||||
// This is the same information as in ListCamerasRow minus the stuff
|
||||
// that's calculable from ListCameraRecordingsRow, which the camera details
|
||||
// webpage also grabs.
|
||||
struct GetCameraRow {
|
||||
int64_t retain_bytes = -1;
|
||||
Uuid uuid;
|
||||
std::string short_name;
|
||||
std::string description;
|
||||
};
|
||||
|
||||
// For use with MoonfireDatabase::ListCameraRecordings.
|
||||
struct ListCameraRecordingsRow {
|
||||
// From the recording table.
|
||||
int64_t start_time_90k = -1;
|
||||
int64_t end_time_90k = -1;
|
||||
int64_t video_samples = -1;
|
||||
int64_t sample_file_bytes = -1;
|
||||
std::string video_sample_entry_sha1;
|
||||
|
||||
// Joined from the video_sample_entry table.
|
||||
int64_t width = -1;
|
||||
int64_t height = -1;
|
||||
};
|
||||
|
||||
class MoonfireDatabase {
|
||||
public:
|
||||
explicit MoonfireDatabase(Database *db) : db_(db) {}
|
||||
MoonfireDatabase(const MoonfireDatabase &) = delete;
|
||||
void operator=(const MoonfireDatabase &) = delete;
|
||||
|
||||
bool Init(std::string *error_message);
|
||||
|
||||
// List all cameras in the system, ordered by short name.
|
||||
// Holds database lock; callback should be quick.
|
||||
bool ListCameras(std::function<IterationControl(const ListCamerasRow &)> cb,
|
||||
std::string *error_message);
|
||||
|
||||
bool GetCamera(int64_t camera_id, GetCameraRow *row,
|
||||
std::string *error_message);
|
||||
|
||||
// List all recordings associated with a camera, ordered by start time..
|
||||
// Holds database lock; callback should be quick.
|
||||
bool ListCameraRecordings(
|
||||
int64_t camera_id,
|
||||
std::function<IterationControl(const ListCameraRecordingsRow &)>,
|
||||
std::string *error_message);
|
||||
|
||||
std::shared_ptr<VirtualFile> BuildMp4(int64_t camera_id,
|
||||
int64_t start_time_90k,
|
||||
int64_t end_time_90k,
|
||||
std::string *error_message);
|
||||
|
||||
private:
|
||||
Database *const db_;
|
||||
Statement list_cameras_query_;
|
||||
Statement get_camera_query_;
|
||||
Statement list_camera_recordings_query_;
|
||||
Statement build_mp4_query_;
|
||||
};
|
||||
|
||||
} // namespace moonfire_nvr
|
||||
|
||||
#endif // MOONFIRE_NVR_MOONFIRE_DB_H
|
@ -211,4 +211,15 @@ bool SampleFileWriter::Close(std::string *sha1, std::string *error_message) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
std::string PrettyTimestamp(int64_t ts_90k) {
|
||||
struct tm mytm;
|
||||
memset(&mytm, 0, sizeof(mytm));
|
||||
time_t ts = ts_90k / kTimeUnitsPerSecond;
|
||||
localtime_r(&ts, &mytm);
|
||||
const size_t kTimeBufLen = 50;
|
||||
char tmbuf[kTimeBufLen];
|
||||
strftime(tmbuf, kTimeBufLen, "%a, %d %b %Y %H:%M:%S %Z", &mytm);
|
||||
return tmbuf;
|
||||
}
|
||||
|
||||
} // namespace moonfire_nvr
|
||||
|
@ -194,6 +194,8 @@ struct VideoSampleEntry {
|
||||
uint16_t height = 0;
|
||||
};
|
||||
|
||||
std::string PrettyTimestamp(int64_t ts_90k);
|
||||
|
||||
} // namespace moonfire_nvr
|
||||
|
||||
#endif // MOONFIRE_NVR_RECORDING_H
|
||||
|
@ -145,6 +145,41 @@ std::string HumanizeWithBinaryPrefix(float n, re2::StringPiece suffix) {
|
||||
return Humanize(kPrefixes, 1024., n, suffix);
|
||||
}
|
||||
|
||||
std::string HumanizeDuration(int64_t seconds) {
|
||||
static const int64_t kMinuteInSeconds = 60;
|
||||
static const int64_t kHourInSeconds = 60 * kMinuteInSeconds;
|
||||
static const int64_t kDayInSeconds = 24 * kHourInSeconds;
|
||||
int64_t days = seconds / kDayInSeconds;
|
||||
seconds %= kDayInSeconds;
|
||||
int64_t hours = seconds / kHourInSeconds;
|
||||
seconds %= kHourInSeconds;
|
||||
int64_t minutes = seconds / kMinuteInSeconds;
|
||||
seconds %= kMinuteInSeconds;
|
||||
std::string out;
|
||||
if (days > 0) {
|
||||
out.append(StrCat(days, days == 1 ? " day" : " days"));
|
||||
}
|
||||
if (hours > 0) {
|
||||
if (!out.empty()) {
|
||||
out.append(" ");
|
||||
}
|
||||
out.append(StrCat(hours, hours == 1 ? " hour" : " hours"));
|
||||
}
|
||||
if (minutes > 0) {
|
||||
if (!out.empty()) {
|
||||
out.append(" ");
|
||||
}
|
||||
out.append(StrCat(minutes, minutes == 1 ? " minute" : " minutes"));
|
||||
}
|
||||
if (seconds > 0 || out.empty()) {
|
||||
if (!out.empty()) {
|
||||
out.append(" ");
|
||||
}
|
||||
out.append(StrCat(seconds, seconds == 1 ? " second" : " seconds"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool strto64(const char *str, int base, const char **endptr, int64_t *value) {
|
||||
static_assert(sizeof(int64_t) == sizeof(long long int),
|
||||
"unknown memory model");
|
||||
|
@ -127,6 +127,8 @@ std::string ToHex(re2::StringPiece in, bool pad = false);
|
||||
std::string HumanizeWithDecimalPrefix(float n, re2::StringPiece suffix);
|
||||
std::string HumanizeWithBinaryPrefix(float n, re2::StringPiece suffix);
|
||||
|
||||
std::string HumanizeDuration(int64_t sec);
|
||||
|
||||
// Wrapper around ::strtoll that returns true iff valid and corrects
|
||||
// constness. Returns false if |str| is null.
|
||||
bool strto64(const char *str, int base, const char **endptr, int64_t *value);
|
||||
|
224
src/web.cc
Normal file
224
src/web.cc
Normal file
@ -0,0 +1,224 @@
|
||||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2016 Scott Lamb <slamb@slamb.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// In addition, as a special exception, the copyright holders give
|
||||
// permission to link the code of portions of this program with the
|
||||
// OpenSSL library under certain conditions as described in each
|
||||
// individual source file, and distribute linked combinations including
|
||||
// the two.
|
||||
//
|
||||
// You must obey the GNU General Public License in all respects for all
|
||||
// of the code used other than OpenSSL. If you modify file(s) with this
|
||||
// exception, you may extend this exception to your version of the
|
||||
// file(s), but you are not obligated to do so. If you do not wish to do
|
||||
// so, delete this exception statement from your version. If you delete
|
||||
// this exception statement from all source files in the program, then
|
||||
// also delete it here.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// web.cc: implementation of web.h interface.
|
||||
|
||||
#include "web.h"
|
||||
|
||||
#include <glog/logging.h>
|
||||
|
||||
#include "recording.h"
|
||||
#include "string.h"
|
||||
|
||||
namespace moonfire_nvr {
|
||||
|
||||
void WebInterface::Register(evhttp *http) {
|
||||
evhttp_set_cb(http, "/", &WebInterface::HandleCameraList, this);
|
||||
evhttp_set_cb(http, "/camera", &WebInterface::HandleCameraDetail, this);
|
||||
evhttp_set_cb(http, "/view.mp4", &WebInterface::HandleMp4View, this);
|
||||
}
|
||||
|
||||
void WebInterface::HandleCameraList(evhttp_request *req, void *arg) {
|
||||
auto *this_ = reinterpret_cast<WebInterface *>(arg);
|
||||
EvBuffer buf;
|
||||
buf.Add(
|
||||
"<!DOCTYPE html>\n"
|
||||
"<html>\n"
|
||||
"<head>\n"
|
||||
"<title>Camera list</title>\n"
|
||||
"<style type=\"text/css\">\n"
|
||||
".header { background-color: #ddd; }\n"
|
||||
"td { padding-right: 3em; }\n"
|
||||
"</style>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
"<table>\n");
|
||||
auto row_cb = [&](const ListCamerasRow &row) {
|
||||
auto seconds =
|
||||
(row.max_recording_end_time_90k - row.min_recording_start_time_90k) /
|
||||
kTimeUnitsPerSecond;
|
||||
buf.AddPrintf(
|
||||
"<tr class=header><td colspan=2><a href=\"/camera?id=%" PRId64
|
||||
"\">%s</a></td></tr>\n"
|
||||
"<tr><td>description</td><td>%s</td></tr>\n"
|
||||
"<tr><td>space</td><td>%s / %s (%.1f%%)</td></tr>\n"
|
||||
"<tr><td>uuid</td><td>%s</td></tr>\n"
|
||||
"<tr><td>oldest recording</td><td>%s</td></tr>\n"
|
||||
"<tr><td>newest recording</td><td>%s</td></tr>\n"
|
||||
"<tr><td>total duration</td><td>%s</td></tr>\n",
|
||||
row.id, EscapeHtml(row.short_name).c_str(),
|
||||
EscapeHtml(row.description).c_str(),
|
||||
EscapeHtml(HumanizeWithBinaryPrefix(row.total_sample_file_bytes, "B"))
|
||||
.c_str(),
|
||||
EscapeHtml(HumanizeWithBinaryPrefix(row.retain_bytes, "B")).c_str(),
|
||||
100.f * row.total_sample_file_bytes / row.retain_bytes,
|
||||
EscapeHtml(row.uuid.UnparseText()).c_str(),
|
||||
EscapeHtml(PrettyTimestamp(row.min_recording_start_time_90k)).c_str(),
|
||||
EscapeHtml(PrettyTimestamp(row.max_recording_end_time_90k)).c_str(),
|
||||
EscapeHtml(HumanizeDuration(seconds)).c_str());
|
||||
return IterationControl::kContinue;
|
||||
};
|
||||
std::string error_message;
|
||||
if (!this_->mdb_->ListCameras(row_cb, &error_message)) {
|
||||
return evhttp_send_error(req, HTTP_INTERNAL,
|
||||
EscapeHtml(error_message).c_str());
|
||||
}
|
||||
buf.Add(
|
||||
"</table>\n"
|
||||
"</body>\n"
|
||||
"<html>\n");
|
||||
evhttp_send_reply(req, HTTP_OK, "OK", buf.get());
|
||||
}
|
||||
|
||||
void WebInterface::HandleCameraDetail(evhttp_request *req, void *arg) {
|
||||
auto *this_ = reinterpret_cast<WebInterface *>(arg);
|
||||
|
||||
int64_t camera_id;
|
||||
QueryParameters params(evhttp_request_get_uri(req));
|
||||
if (!params.ok() || !Atoi64(params.Get("id"), 10, &camera_id)) {
|
||||
return evhttp_send_error(req, HTTP_BADREQUEST, "bad query parameters");
|
||||
}
|
||||
|
||||
GetCameraRow camera_row;
|
||||
std::string error_message;
|
||||
if (!this_->mdb_->GetCamera(camera_id, &camera_row, &error_message)) {
|
||||
// TODO: more nuanced error here, such as HTTP_NOTFOUND where appropriate.
|
||||
return evhttp_send_error(
|
||||
req, HTTP_INTERNAL,
|
||||
StrCat("sqlite query failed: ", EscapeHtml(error_message)).c_str());
|
||||
}
|
||||
|
||||
EvBuffer buf;
|
||||
buf.AddPrintf(
|
||||
"<!DOCTYPE html>\n"
|
||||
"<html>\n"
|
||||
"<head>\n"
|
||||
"<title>%s recordings</title>\n"
|
||||
"<style type=\"text/css\">\n"
|
||||
"tr:not(:first-child):hover { background-color: #ddd; }\n"
|
||||
"th, td { padding: 0.5ex 1.5em; text-align: right; }\n"
|
||||
"</style>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
"<h1>%s</h1>\n"
|
||||
"<p>%s</p>\n"
|
||||
"<table>\n"
|
||||
"<tr><th>start</th><th>end</th><th>resolution</th>"
|
||||
"<th>fps</th><th>size</th><th>bitrate</th>"
|
||||
"</tr>\n",
|
||||
EscapeHtml(camera_row.short_name).c_str(),
|
||||
EscapeHtml(camera_row.short_name).c_str(),
|
||||
EscapeHtml(camera_row.description).c_str());
|
||||
|
||||
// Rather than listing each 60-second recording, generate a HTML row for
|
||||
// aggregated .mp4 files of up to kForceSplitDuration90k each, provided
|
||||
// there is no gap or change in video parameters between recordings.
|
||||
static const int64_t kForceSplitDuration90k =
|
||||
4 * 60 * 60 * kTimeUnitsPerSecond;
|
||||
ListCameraRecordingsRow aggregated;
|
||||
auto maybe_finish_html_row = [&]() {
|
||||
if (aggregated.start_time_90k == -1) {
|
||||
return; // there is no row to finish.
|
||||
}
|
||||
auto seconds = static_cast<float>(aggregated.end_time_90k -
|
||||
aggregated.start_time_90k) /
|
||||
kTimeUnitsPerSecond;
|
||||
buf.AddPrintf(
|
||||
"<tr><td><a href=\"/view.mp4?camera_id=%" PRId64
|
||||
"&start_time_90k=%" PRId64 "&end_time_90k=%" PRId64
|
||||
"\">%s</a></td><td>%s</td><td>%dx%d</td>"
|
||||
"<td>%.0f</td><td>%s</td><td>%s</td></tr>\n",
|
||||
camera_id, aggregated.start_time_90k, aggregated.end_time_90k,
|
||||
PrettyTimestamp(aggregated.start_time_90k).c_str(),
|
||||
PrettyTimestamp(aggregated.end_time_90k).c_str(),
|
||||
static_cast<int>(aggregated.width), static_cast<int>(aggregated.height),
|
||||
static_cast<float>(aggregated.video_samples) / seconds,
|
||||
HumanizeWithBinaryPrefix(aggregated.sample_file_bytes, "B").c_str(),
|
||||
HumanizeWithDecimalPrefix(
|
||||
static_cast<float>(aggregated.sample_file_bytes) * 8 / seconds,
|
||||
"bps")
|
||||
.c_str());
|
||||
};
|
||||
auto handle_sql_row = [&](const ListCameraRecordingsRow &row) {
|
||||
auto new_duration_90k = row.end_time_90k - aggregated.start_time_90k;
|
||||
if (row.video_sample_entry_sha1 == aggregated.video_sample_entry_sha1 &&
|
||||
row.start_time_90k == aggregated.end_time_90k &&
|
||||
new_duration_90k < kForceSplitDuration90k) {
|
||||
// Append to current .mp4.
|
||||
aggregated.end_time_90k = row.end_time_90k;
|
||||
aggregated.video_samples += row.video_samples;
|
||||
aggregated.sample_file_bytes += row.sample_file_bytes;
|
||||
} else {
|
||||
// Start a new .mp4.
|
||||
maybe_finish_html_row();
|
||||
aggregated = row;
|
||||
}
|
||||
return IterationControl::kContinue;
|
||||
};
|
||||
if (!this_->mdb_->ListCameraRecordings(camera_id, handle_sql_row,
|
||||
&error_message)) {
|
||||
return evhttp_send_error(
|
||||
req, HTTP_INTERNAL,
|
||||
StrCat("sqlite query failed: ", EscapeHtml(error_message)).c_str());
|
||||
}
|
||||
maybe_finish_html_row();
|
||||
buf.Add(
|
||||
"</table>\n"
|
||||
"</html>\n");
|
||||
evhttp_send_reply(req, HTTP_OK, "OK", buf.get());
|
||||
}
|
||||
|
||||
void WebInterface::HandleMp4View(evhttp_request *req, void *arg) {
|
||||
auto *this_ = reinterpret_cast<WebInterface *>(arg);
|
||||
|
||||
int64_t camera_id;
|
||||
int64_t start_time_90k;
|
||||
int64_t end_time_90k;
|
||||
QueryParameters params(evhttp_request_get_uri(req));
|
||||
if (!params.ok() || !Atoi64(params.Get("camera_id"), 10, &camera_id) ||
|
||||
!Atoi64(params.Get("start_time_90k"), 10, &start_time_90k) ||
|
||||
!Atoi64(params.Get("end_time_90k"), 10, &end_time_90k) ||
|
||||
start_time_90k < 0 || start_time_90k >= end_time_90k) {
|
||||
return evhttp_send_error(req, HTTP_BADREQUEST, "bad query parameters");
|
||||
}
|
||||
|
||||
std::string error_message;
|
||||
auto file = this_->mdb_->BuildMp4(camera_id, start_time_90k, end_time_90k,
|
||||
&error_message);
|
||||
if (file == nullptr) {
|
||||
// TODO: more nuanced HTTP status codes.
|
||||
return evhttp_send_error(req, HTTP_INTERNAL,
|
||||
EscapeHtml(error_message).c_str());
|
||||
}
|
||||
|
||||
return HttpServe(file, req);
|
||||
}
|
||||
|
||||
} // namespace moonfire_nvr
|
78
src/web.h
Normal file
78
src/web.h
Normal file
@ -0,0 +1,78 @@
|
||||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2016 Scott Lamb <slamb@slamb.org>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// In addition, as a special exception, the copyright holders give
|
||||
// permission to link the code of portions of this program with the
|
||||
// OpenSSL library under certain conditions as described in each
|
||||
// individual source file, and distribute linked combinations including
|
||||
// the two.
|
||||
//
|
||||
// You must obey the GNU General Public License in all respects for all
|
||||
// of the code used other than OpenSSL. If you modify file(s) with this
|
||||
// exception, you may extend this exception to your version of the
|
||||
// file(s), but you are not obligated to do so. If you do not wish to do
|
||||
// so, delete this exception statement from your version. If you delete
|
||||
// this exception statement from all source files in the program, then
|
||||
// also delete it here.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
// web.h: web (HTTP/HTML) interface to the SQLite-based recording schema.
|
||||
// Currently, during the transition from the old bunch-of-.mp4-files schema to
|
||||
// the SQLite-based schema, it's convenient for this to be a separate class
|
||||
// that interacts with the recording system only through the SQLite database
|
||||
// and filesystem. In fact, the only advantage of being in-process is that it
|
||||
// shares the same database mutex and avoids hitting SQLITE_BUSY.
|
||||
//
|
||||
// In the future, the interface will be reworked for tighter integration to
|
||||
// support more features:
|
||||
//
|
||||
// * including the recording currently being written in the web interface
|
||||
// * subscribing to changes
|
||||
// * reconfiguring the recording system, such as
|
||||
// adding/removing/starting/stopping/editing cameras
|
||||
// * showing thumbnails of the latest key frame from each camera
|
||||
// * ...
|
||||
|
||||
#ifndef MOONFIRE_NVR_WEB_H
|
||||
#define MOONFIRE_NVR_WEB_H
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <event2/http.h>
|
||||
|
||||
#include "moonfire-db.h"
|
||||
#include "http.h"
|
||||
|
||||
namespace moonfire_nvr {
|
||||
|
||||
class WebInterface {
|
||||
public:
|
||||
explicit WebInterface(MoonfireDatabase *mdb) : mdb_(mdb) {}
|
||||
WebInterface(const WebInterface &) = delete;
|
||||
void operator=(const WebInterface &) = delete;
|
||||
|
||||
void Register(evhttp *http);
|
||||
|
||||
private:
|
||||
static void HandleCameraList(evhttp_request *req, void *arg);
|
||||
static void HandleCameraDetail(evhttp_request *req, void *arg);
|
||||
static void HandleMp4View(evhttp_request *req, void *arg);
|
||||
|
||||
MoonfireDatabase *const mdb_;
|
||||
};
|
||||
|
||||
} // namespace moonfire_nvr
|
||||
|
||||
#endif // MOONFIRE_NVR_WEB_H
|
Loading…
Reference in New Issue
Block a user