diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2721ad2..355bf86 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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}) diff --git a/src/http.h b/src/http.h index a28842e..ce1200b 100644 --- a/src/http.h +++ b/src/http.h @@ -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; diff --git a/src/moonfire-db.cc b/src/moonfire-db.cc new file mode 100644 index 0000000..467514a --- /dev/null +++ b/src/moonfire-db.cc @@ -0,0 +1,342 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2016 Scott Lamb +// +// 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 . +// +// moonfire-db.cc: implementation of moonfire-db.h interface. + +#include "moonfire-db.h" + +#include + +#include + +#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 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 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 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::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 diff --git a/src/moonfire-db.h b/src/moonfire-db.h new file mode 100644 index 0000000..e15013a --- /dev/null +++ b/src/moonfire-db.h @@ -0,0 +1,141 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2016 Scott Lamb +// +// 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 . +// +// 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 +#include +#include + +#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 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, + std::string *error_message); + + std::shared_ptr 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 diff --git a/src/recording.cc b/src/recording.cc index af1e2ca..04bd751 100644 --- a/src/recording.cc +++ b/src/recording.cc @@ -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 diff --git a/src/recording.h b/src/recording.h index 43c89b6..1b503a7 100644 --- a/src/recording.h +++ b/src/recording.h @@ -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 diff --git a/src/string.cc b/src/string.cc index b468084..c8a2d1a 100644 --- a/src/string.cc +++ b/src/string.cc @@ -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"); diff --git a/src/string.h b/src/string.h index aa24423..8002f1c 100644 --- a/src/string.h +++ b/src/string.h @@ -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); diff --git a/src/web.cc b/src/web.cc new file mode 100644 index 0000000..32c4d59 --- /dev/null +++ b/src/web.cc @@ -0,0 +1,224 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2016 Scott Lamb +// +// 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 . +// +// web.cc: implementation of web.h interface. + +#include "web.h" + +#include + +#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(arg); + EvBuffer buf; + buf.Add( + "\n" + "\n" + "\n" + "Camera list\n" + "\n" + "\n" + "\n" + "\n"); + auto row_cb = [&](const ListCamerasRow &row) { + auto seconds = + (row.max_recording_end_time_90k - row.min_recording_start_time_90k) / + kTimeUnitsPerSecond; + buf.AddPrintf( + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\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( + "
%s
description%s
space%s / %s (%.1f%%)
uuid%s
oldest recording%s
newest recording%s
total duration%s
\n" + "\n" + "\n"); + evhttp_send_reply(req, HTTP_OK, "OK", buf.get()); +} + +void WebInterface::HandleCameraDetail(evhttp_request *req, void *arg) { + auto *this_ = reinterpret_cast(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( + "\n" + "\n" + "\n" + "%s recordings\n" + "\n" + "\n" + "\n" + "

%s

\n" + "

%s

\n" + "\n" + "" + "" + "\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(aggregated.end_time_90k - + aggregated.start_time_90k) / + kTimeUnitsPerSecond; + buf.AddPrintf( + "" + "\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(aggregated.width), static_cast(aggregated.height), + static_cast(aggregated.video_samples) / seconds, + HumanizeWithBinaryPrefix(aggregated.sample_file_bytes, "B").c_str(), + HumanizeWithDecimalPrefix( + static_cast(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( + "
startendresolutionfpssizebitrate
%s%s%dx%d%.0f%s%s
\n" + "\n"); + evhttp_send_reply(req, HTTP_OK, "OK", buf.get()); +} + +void WebInterface::HandleMp4View(evhttp_request *req, void *arg) { + auto *this_ = reinterpret_cast(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 diff --git a/src/web.h b/src/web.h new file mode 100644 index 0000000..5a56ca9 --- /dev/null +++ b/src/web.h @@ -0,0 +1,78 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2016 Scott Lamb +// +// 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 . +// +// 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 + +#include + +#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