diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 355bf86..ccedc8a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -71,7 +71,19 @@ install_programs(/bin FILES moonfire-nvr) include_directories(${GTest_INCLUDE_DIR}) include_directories(${GMock_INCLUDE_DIR}) -foreach(test coding crypto h264 http moonfire-nvr mp4 recording sqlite string) +set(MOONFIRE_NVR_TESTS + coding + crypto + h264 + http + moonfire-db + moonfire-nvr + mp4 + recording + sqlite + string) + +foreach(test ${MOONFIRE_NVR_TESTS}) add_executable(${test}-test ${test}-test.cc testutil.cc) target_link_libraries(${test}-test GTest GMock moonfire-nvr-lib) add_test(NAME ${test}-test diff --git a/src/moonfire-db-test.cc b/src/moonfire-db-test.cc new file mode 100644 index 0000000..fe6efa4 --- /dev/null +++ b/src/moonfire-db-test.cc @@ -0,0 +1,345 @@ +// +// 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-test.cc: tests of the moonfire-db.h interface. + +#include + +#include +#include +#include + +#include "moonfire-db.h" +#include "sqlite.h" +#include "string.h" +#include "testutil.h" + +DECLARE_bool(alsologtostderr); + +using testing::_; +using testing::HasSubstr; +using testing::DoAll; +using testing::Return; +using testing::SetArgPointee; + +namespace moonfire_nvr { +namespace { + +class MoonfireDbTest : public testing::Test { + protected: + MoonfireDbTest() { + tmpdir_ = PrepareTempDirOrDie("moonfire-db-test"); + std::string error_message; + CHECK(db_.Open(StrCat(tmpdir_, "/db").c_str(), + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, &error_message)) + << error_message; + std::string create_sql = ReadFileOrDie("../src/schema.sql"); + DatabaseContext ctx(&db_); + CHECK(RunStatements(&ctx, create_sql, &error_message)) << error_message; + } + + int64_t AddCamera(Uuid uuid, re2::StringPiece short_name) { + DatabaseContext ctx(&db_); + auto run = ctx.UseOnce( + R"( + insert into camera (uuid, short_name, retain_bytes) + values (:uuid, :short_name, :retain_bytes); + )"); + run.BindBlob(":uuid", uuid.binary_view()); + run.BindText(":short_name", short_name); + run.BindInt64(":retain_bytes", 42); + if (run.Step() != SQLITE_DONE) { + ADD_FAILURE() << run.error_message(); + return -1; + } + return ctx.last_insert_rowid(); + } + + void ExpectNoRecordings(Uuid camera_uuid) { + int rows = 0; + mdb_->ListCameras([&](const ListCamerasRow &row) { + ++rows; + EXPECT_EQ(camera_uuid, row.uuid); + EXPECT_EQ(-1, row.min_start_time_90k); + EXPECT_EQ(-1, row.max_end_time_90k); + EXPECT_EQ(0, row.total_duration_90k); + EXPECT_EQ(0, row.total_sample_file_bytes); + return IterationControl::kContinue; + }); + EXPECT_EQ(1, rows); + + std::string error_message; + rows = 0; + EXPECT_TRUE(mdb_->ListCameraRecordings( + camera_uuid, 0, std::numeric_limits::max(), + [&](const ListCameraRecordingsRow &row) { + ++rows; + return IterationControl::kBreak; + }, + &error_message)) + << error_message; + EXPECT_EQ(0, rows); + + rows = 0; + EXPECT_TRUE(mdb_->ListMp4Recordings( + camera_uuid, 0, std::numeric_limits::max(), + [&](Recording &recording, const VideoSampleEntry &entry) { + ++rows; + return IterationControl::kBreak; + }, + &error_message)) + << error_message; + EXPECT_EQ(0, rows); + } + + void ExpectSingleRecording(Uuid camera_uuid, const Recording &recording, + const VideoSampleEntry &entry, + ListOldestSampleFilesRow *save_oldest_row) { + std::string error_message; + int rows = 0; + mdb_->ListCameras([&](const ListCamerasRow &row) { + ++rows; + EXPECT_EQ(camera_uuid, row.uuid); + EXPECT_EQ(recording.start_time_90k, row.min_start_time_90k); + EXPECT_EQ(recording.end_time_90k, row.max_end_time_90k); + EXPECT_EQ(recording.end_time_90k - recording.start_time_90k, + row.total_duration_90k); + EXPECT_EQ(recording.sample_file_bytes, row.total_sample_file_bytes); + return IterationControl::kContinue; + }); + EXPECT_EQ(1, rows); + + GetCameraRow camera_row; + EXPECT_TRUE(mdb_->GetCamera(camera_uuid, &camera_row)); + EXPECT_EQ(recording.start_time_90k, camera_row.min_start_time_90k); + EXPECT_EQ(recording.end_time_90k, camera_row.max_end_time_90k); + EXPECT_EQ(recording.end_time_90k - recording.start_time_90k, + camera_row.total_duration_90k); + EXPECT_EQ(recording.sample_file_bytes, camera_row.total_sample_file_bytes); + + rows = 0; + EXPECT_TRUE(mdb_->ListCameraRecordings( + camera_uuid, 0, std::numeric_limits::max(), + [&](const ListCameraRecordingsRow &row) { + ++rows; + EXPECT_EQ(recording.start_time_90k, row.start_time_90k); + EXPECT_EQ(recording.end_time_90k, row.end_time_90k); + EXPECT_EQ(recording.video_samples, row.video_samples); + EXPECT_EQ(recording.sample_file_bytes, row.sample_file_bytes); + EXPECT_EQ(entry.sha1, row.video_sample_entry_sha1); + EXPECT_EQ(entry.width, row.width); + EXPECT_EQ(entry.height, row.height); + return IterationControl::kContinue; + }, + &error_message)) + << error_message; + EXPECT_EQ(1, rows); + + rows = 0; + EXPECT_TRUE(mdb_->ListOldestSampleFiles( + camera_uuid, + [&](const ListOldestSampleFilesRow &row) { + ++rows; + EXPECT_EQ(recording.id, row.recording_id); + EXPECT_EQ(recording.sample_file_uuid, row.sample_file_uuid); + EXPECT_EQ(recording.end_time_90k - recording.start_time_90k, + row.duration_90k); + EXPECT_EQ(recording.sample_file_bytes, row.sample_file_bytes); + *save_oldest_row = row; + return IterationControl::kContinue; + }, + &error_message)) + << error_message; + EXPECT_EQ(1, rows); + + rows = 0; + EXPECT_TRUE(mdb_->ListMp4Recordings( + camera_uuid, 0, std::numeric_limits::max(), + [&](Recording &some_recording, const VideoSampleEntry &some_entry) { + ++rows; + + EXPECT_EQ(recording.id, some_recording.id); + EXPECT_EQ(recording.camera_id, some_recording.camera_id); + EXPECT_EQ(recording.sample_file_sha1, + some_recording.sample_file_sha1); + EXPECT_EQ(recording.sample_file_uuid, + some_recording.sample_file_uuid); + EXPECT_EQ(recording.video_sample_entry_id, + some_recording.video_sample_entry_id); + EXPECT_EQ(recording.start_time_90k, some_recording.start_time_90k); + EXPECT_EQ(recording.end_time_90k, some_recording.end_time_90k); + EXPECT_EQ(recording.sample_file_bytes, + some_recording.sample_file_bytes); + EXPECT_EQ(recording.video_samples, some_recording.video_samples); + EXPECT_EQ(recording.video_sync_samples, + some_recording.video_sync_samples); + EXPECT_EQ(recording.video_index, some_recording.video_index); + + EXPECT_EQ(entry.id, some_entry.id); + EXPECT_EQ(entry.sha1, some_entry.sha1); + EXPECT_EQ(entry.data, some_entry.data); + EXPECT_EQ(entry.width, some_entry.width); + EXPECT_EQ(entry.height, some_entry.height); + + return IterationControl::kContinue; + }, + &error_message)) + << error_message; + EXPECT_EQ(1, rows); + } + + std::string tmpdir_; + Database db_; + std::unique_ptr mdb_; +}; + +// Basic test of running some queries on an empty database. +TEST_F(MoonfireDbTest, EmptyDatabase) { + std::string error_message; + mdb_.reset(new MoonfireDatabase); + ASSERT_TRUE(mdb_->Init(&db_, &error_message)) << error_message; + + mdb_->ListCameras([&](const ListCamerasRow &row) { + ADD_FAILURE() << "row unexpected"; + return IterationControl::kBreak; + }); + + GetCameraRow get_camera_row; + EXPECT_FALSE(mdb_->GetCamera(Uuid(), &get_camera_row)); + + EXPECT_FALSE( + mdb_->ListCameraRecordings(Uuid(), 0, std::numeric_limits::max(), + [&](const ListCameraRecordingsRow &row) { + ADD_FAILURE() << "row unexpected"; + return IterationControl::kBreak; + }, + &error_message)); + + EXPECT_FALSE(mdb_->ListMp4Recordings( + Uuid(), 0, std::numeric_limits::max(), + [&](Recording &recording, const VideoSampleEntry &entry) { + ADD_FAILURE() << "row unexpected"; + return IterationControl::kBreak; + }, + &error_message)); +} + +// Basic test of the full lifecycle of recording. +// Does not exercise many error cases. +TEST_F(MoonfireDbTest, FullLifecycle) { + std::string error_message; + const char kCameraShortName[] = "testcam"; + Uuid camera_uuid = GetRealUuidGenerator()->Generate(); + int64_t camera_id = AddCamera(camera_uuid, kCameraShortName); + ASSERT_GT(camera_id, 0); + mdb_.reset(new MoonfireDatabase); + ASSERT_TRUE(mdb_->Init(&db_, &error_message)) << error_message; + + ExpectNoRecordings(camera_uuid); + + std::vector reserved; + EXPECT_TRUE(mdb_->ListReservedSampleFiles(&reserved, &error_message)) + << error_message; + EXPECT_THAT(reserved, testing::IsEmpty()); + + std::vector uuids = mdb_->ReserveSampleFiles(2, &error_message); + ASSERT_THAT(uuids, testing::SizeIs(2)) << error_message; + + EXPECT_TRUE(mdb_->ListReservedSampleFiles(&reserved, &error_message)) + << error_message; + EXPECT_THAT(reserved, testing::UnorderedElementsAre(uuids[0], uuids[1])); + + VideoSampleEntry entry; + entry.sha1.resize(20); + entry.width = 768; + entry.height = 512; + entry.data.resize(100); + ASSERT_TRUE(mdb_->InsertVideoSampleEntry(&entry, &error_message)) + << error_message; + ASSERT_GT(entry.id, 0); + + Recording recording; + recording.camera_id = camera_id; + recording.sample_file_uuid = GetRealUuidGenerator()->Generate(); + recording.video_sample_entry_id = entry.id; + SampleIndexEncoder encoder; + encoder.Init(&recording, UINT64_C(1430006400) * kTimeUnitsPerSecond); + encoder.AddSample(kTimeUnitsPerSecond, 42, true); + + // Inserting a recording should succeed and remove its uuid from the + // reserved table. + ASSERT_FALSE(mdb_->InsertRecording(&recording, &error_message)); + EXPECT_THAT(error_message, testing::HasSubstr("not reserved")); + recording.sample_file_uuid = uuids.back(); + recording.sample_file_sha1.resize(20); + ASSERT_TRUE(mdb_->InsertRecording(&recording, &error_message)) + << error_message; + ASSERT_GT(recording.id, 0); + EXPECT_TRUE(mdb_->ListReservedSampleFiles(&reserved, &error_message)) + << error_message; + EXPECT_THAT(reserved, testing::ElementsAre(uuids[0])); + + // Queries should return the correct result (with caches updated on insert). + ListOldestSampleFilesRow oldest; + ExpectSingleRecording(camera_uuid, recording, entry, &oldest); + + // Queries on a fresh database should return the correct result (with caches + // populated from existing database contents). + mdb_.reset(new MoonfireDatabase); + ASSERT_TRUE(mdb_->Init(&db_, &error_message)) << error_message; + ExpectSingleRecording(camera_uuid, recording, entry, &oldest); + + // Deleting a recording should succeed, update the min/max times, and mark + // the uuid as reserved. + std::vector to_delete; + to_delete.push_back(oldest); + ASSERT_TRUE(mdb_->DeleteRecordings(to_delete, &error_message)) + << error_message; + EXPECT_TRUE(mdb_->ListReservedSampleFiles(&reserved, &error_message)) + << error_message; + EXPECT_THAT(reserved, testing::UnorderedElementsAre(uuids[0], uuids[1])); + LOG(INFO) << "after delete"; + ExpectNoRecordings(camera_uuid); + + EXPECT_TRUE(mdb_->MarkSampleFilesDeleted(uuids, &error_message)) + << error_message; + EXPECT_TRUE(mdb_->ListReservedSampleFiles(&reserved, &error_message)) + << error_message; + EXPECT_THAT(reserved, testing::IsEmpty()); +} + +} // namespace +} // namespace moonfire_nvr + +int main(int argc, char **argv) { + FLAGS_alsologtostderr = true; + google::ParseCommandLineFlags(&argc, &argv, true); + testing::InitGoogleTest(&argc, argv); + google::InitGoogleLogging(argv[0]); + return RUN_ALL_TESTS(); +} diff --git a/src/moonfire-db.cc b/src/moonfire-db.cc index ffbc3e4..7e77a39 100644 --- a/src/moonfire-db.cc +++ b/src/moonfire-db.cc @@ -29,6 +29,7 @@ // along with this program. If not, see . // // moonfire-db.cc: implementation of moonfire-db.h interface. +// see top-level comments there on performance & efficiency. #include "moonfire-db.h" @@ -42,188 +43,336 @@ 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; +bool MoonfireDatabase::Init(Database *db, std::string *error_message) { + CHECK(db_ == nullptr); + db_ = db; + + { + DatabaseContext ctx(db_); + + // This query scans the entirety of the recording table's index. + // It is quite slow, so the results are cached. + auto list_cameras_run = ctx.UseOnce( + R"( + select + camera.id, + camera.uuid, + camera.short_name, + camera.description, + camera.retain_bytes, + min(recording.start_time_90k), + max(recording.start_time_90k + recording.duration_90k), + sum(recording.duration_90k), + sum(recording.sample_file_bytes) + from + camera + left join recording on (camera.id = recording.camera_id) + group by + camera.id, + camera.uuid, + camera.short_name, + camera.description, + camera.retain_bytes; + )"); + while (list_cameras_run.Step() == SQLITE_ROW) { + CameraData data; + data.id = list_cameras_run.ColumnInt64(0); + Uuid uuid; + if (!uuid.ParseBinary(list_cameras_run.ColumnBlob(1))) { + *error_message = + StrCat("bad uuid ", ToHex(list_cameras_run.ColumnBlob(2)), + " for camera id ", data.id); + return false; + } + data.short_name = list_cameras_run.ColumnText(2).as_string(); + data.description = list_cameras_run.ColumnText(3).as_string(); + data.retain_bytes = list_cameras_run.ColumnInt64(4); + data.min_start_time_90k = list_cameras_run.ColumnType(5) == SQLITE_NULL + ? -1 + : list_cameras_run.ColumnInt64(5); + data.max_end_time_90k = list_cameras_run.ColumnType(6) == SQLITE_NULL + ? -1 + : list_cameras_run.ColumnInt64(6); + data.total_duration_90k = list_cameras_run.ColumnInt64(7); + data.total_sample_file_bytes = list_cameras_run.ColumnInt64(8); + + auto ret = cameras_by_uuid_.insert(std::make_pair(uuid, data)); + if (!ret.second) { + *error_message = StrCat("Duplicate camera uuid ", uuid.UnparseText()); + return false; + } + CameraData *data_p = &ret.first->second; + if (!cameras_by_id_.insert(std::make_pair(data.id, data_p)).second) { + *error_message = StrCat("Duplicate camera id ", data.id); + return false; + } + } + if (list_cameras_run.status() != SQLITE_DONE) { + *error_message = StrCat("Camera list query failed: ", + list_cameras_run.error_message()); + } + + // It's simplest to just keep the video sample entries in RAM. + auto video_sample_entries_run = ctx.UseOnce( + R"( + select + id, + sha1, + width, + height, + data + from + video_sample_entry + )"); + while (video_sample_entries_run.Step() == SQLITE_ROW) { + VideoSampleEntry entry; + entry.id = video_sample_entries_run.ColumnInt64(0); + entry.sha1 = video_sample_entries_run.ColumnBlob(1).as_string(); + int64_t width_tmp = video_sample_entries_run.ColumnInt64(2); + int64_t height_tmp = video_sample_entries_run.ColumnInt64(3); + auto max = std::numeric_limits::max(); + if (width_tmp <= 0 || width_tmp > max || height_tmp <= 0 || + height_tmp > max) { + *error_message = + StrCat("video_sample_entry id ", entry.id, " width ", width_tmp, + " / height ", height_tmp, " out of range."); + return false; + } + entry.width = width_tmp; + entry.height = height_tmp; + entry.data = video_sample_entries_run.ColumnBlob(4).as_string(); + CHECK( + video_sample_entries_.insert(std::make_pair(entry.id, entry)).second) + << "duplicate: " << entry.id; + } } - 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( + std::string list_camera_recordings_sql = StrCat( R"( select recording.start_time_90k, - recording.end_time_90k, + recording.duration_90k, recording.video_samples, recording.sample_file_bytes, - recording.video_sample_entry_sha1, - video_sample_entry.width, - video_sample_entry.height + recording.video_sample_entry_id 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 + camera_id = :camera_id and + recording.start_time_90k > :start_time_90k - )", + kMaxRecordingDuration, " and\n", + R"( + recording.start_time_90k < :end_time_90k and + recording.start_time_90k + recording.duration_90k > :start_time_90k order by - recording.start_time_90k;)", - nullptr, error_message); - if (!list_camera_recordings_query_.valid()) { + recording.start_time_90k desc;)"); + list_camera_recordings_stmt_ = + db_->Prepare(list_camera_recordings_sql, nullptr, error_message); + if (!list_camera_recordings_stmt_.valid()) { return false; } std::string build_mp4_sql = StrCat( R"( select - recording.rowid, + recording.id, recording.start_time_90k, - recording.end_time_90k, + recording.duration_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 + recording.video_sample_entry_id from - recording join video_sample_entry on - (recording.video_sample_entry_sha1 = video_sample_entry.sha1) + recording where - recording.status = 1 and camera_id = :camera_id and recording.start_time_90k > :start_time_90k - )", kMaxRecordingDuration, " and\n", R"( recording.start_time_90k < :end_time_90k and - recording.end_time_90k > :start_time_90k + recording.start_time_90k + recording.duration_90k > :start_time_90k order by recording.start_time_90k;)"); - build_mp4_query_ = db_->Prepare(build_mp4_sql, nullptr, error_message); - if (!build_mp4_query_.valid()) { + build_mp4_stmt_ = db_->Prepare(build_mp4_sql, nullptr, error_message); + if (!build_mp4_stmt_.valid()) { + return false; + } + + insert_reservation_stmt_ = db_->Prepare( + "insert into reserved_sample_files (uuid, state)\n" + " values (:uuid, :state);", + nullptr, error_message); + if (!insert_reservation_stmt_.valid()) { + return false; + } + + delete_reservation_stmt_ = + db_->Prepare("delete from reserved_sample_files where uuid = :uuid;", + nullptr, error_message); + if (!delete_reservation_stmt_.valid()) { + return false; + } + + insert_video_sample_entry_stmt_ = db_->Prepare( + R"( + insert into video_sample_entry (sha1, width, height, data) + values (:sha1, :width, :height, :data); + )", + nullptr, error_message); + if (!insert_video_sample_entry_stmt_.valid()) { + return false; + } + + insert_recording_stmt_ = db_->Prepare( + R"( + insert into recording (camera_id, sample_file_bytes, start_time_90k, + duration_90k, video_samples, video_sync_samples, + video_sample_entry_id, sample_file_uuid, + sample_file_sha1, video_index) + values (:camera_id, :sample_file_bytes, :start_time_90k, + :duration_90k, :video_samples, :video_sync_samples, + :video_sample_entry_id, :sample_file_uuid, + :sample_file_sha1, :video_index); + )", + nullptr, error_message); + if (!insert_recording_stmt_.valid()) { + return false; + } + + list_oldest_sample_files_stmt_ = db_->Prepare( + R"( + select + id, + sample_file_uuid, + duration_90k, + sample_file_bytes + from + recording + where + camera_id = :camera_id + order by + start_time_90k + )", + nullptr, error_message); + if (!list_oldest_sample_files_stmt_.valid()) { + return false; + } + + delete_recording_stmt_ = + db_->Prepare("delete from recording where id = :recording_id;", nullptr, + error_message); + if (!delete_recording_stmt_.valid()) { + return false; + } + + camera_min_start_stmt_ = db_->Prepare( + R"( + select + start_time_90k + from + recording + where + camera_id = :camera_id + order by start_time_90k limit 1; + )", + nullptr, error_message); + if (!camera_min_start_stmt_.valid()) { + return false; + } + + camera_max_start_stmt_ = db_->Prepare( + R"( + select + start_time_90k, + duration_90k + from + recording + where + camera_id = :camera_id + order by start_time_90k desc; + )", + nullptr, error_message); + if (!camera_max_start_stmt_.valid()) { return false; } return true; } -bool MoonfireDatabase::ListCameras( - std::function cb, - std::string *error_message) { +void MoonfireDatabase::ListCameras( + std::function cb) { 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); + for (const auto &entry : cameras_by_uuid_) { + row.uuid = entry.first; + row.short_name = entry.second.short_name; + row.description = entry.second.description; + row.retain_bytes = entry.second.retain_bytes; + row.min_start_time_90k = entry.second.min_start_time_90k; + row.max_end_time_90k = entry.second.max_end_time_90k; + row.total_duration_90k = entry.second.total_duration_90k; + row.total_sample_file_bytes = entry.second.total_sample_file_bytes; if (cb(row) == IterationControl::kBreak) { - break; + return; } } - if (run.status() != SQLITE_DONE) { - *error_message = StrCat("sqlite query failed: ", run.error_message()); - return false; - } - return true; + return; } -bool MoonfireDatabase::GetCamera(int64_t camera_id, GetCameraRow *row, - std::string *error_message) { +bool MoonfireDatabase::GetCamera(Uuid camera_uuid, GetCameraRow *row) { 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"; + const auto it = cameras_by_uuid_.find(camera_uuid); + if (it == cameras_by_uuid_.end()) { return false; } + const CameraData &data = it->second; + row->short_name = data.short_name; + row->description = data.description; + row->retain_bytes = data.retain_bytes; + row->min_start_time_90k = data.min_start_time_90k; + row->max_end_time_90k = data.max_end_time_90k; + row->total_duration_90k = data.total_duration_90k; + row->total_sample_file_bytes = data.total_sample_file_bytes; return true; } bool MoonfireDatabase::ListCameraRecordings( - int64_t camera_id, + Uuid camera_uuid, int64_t start_time_90k, int64_t end_time_90k, std::function cb, std::string *error_message) { DatabaseContext ctx(db_); - auto run = ctx.Borrow(&list_camera_recordings_query_); - run.BindInt64(":camera_id", camera_id); + const auto camera_it = cameras_by_uuid_.find(camera_uuid); + if (camera_it == cameras_by_uuid_.end()) { + *error_message = StrCat("no such camera ", camera_uuid.UnparseText()); + return false; + } + auto run = ctx.Borrow(&list_camera_recordings_stmt_); + run.BindInt64(":camera_id", camera_it->second.id); + run.BindInt64(":start_time_90k", start_time_90k); + run.BindInt64(":end_time_90k", end_time_90k); ListCameraRecordingsRow row; while (run.Step() == SQLITE_ROW) { row.start_time_90k = run.ColumnInt64(0); - row.end_time_90k = run.ColumnInt64(1); + row.end_time_90k = row.start_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); + int64_t video_sample_entry_id = run.ColumnInt64(4); + const auto it = video_sample_entries_.find(video_sample_entry_id); + if (it == video_sample_entries_.end()) { + *error_message = + StrCat("recording references invalid video sample entry ", + video_sample_entry_id); + return false; + } + const VideoSampleEntry &entry = it->second; + row.video_sample_entry_sha1 = entry.sha1; + row.width = entry.width; + row.height = entry.height; if (cb(row) == IterationControl::kBreak) { - break; + return true; } } if (run.status() != SQLITE_DONE) { @@ -234,27 +383,34 @@ bool MoonfireDatabase::ListCameraRecordings( } bool MoonfireDatabase::ListMp4Recordings( - int64_t camera_id, int64_t start_time_90k, int64_t end_time_90k, + Uuid camera_uuid, int64_t start_time_90k, int64_t end_time_90k, std::function row_cb, std::string *error_message) { VLOG(1) << "...(1/4): Waiting for database lock"; DatabaseContext ctx(db_); + const auto it = cameras_by_uuid_.find(camera_uuid); + if (it == cameras_by_uuid_.end()) { + *error_message = StrCat("no such camera ", camera_uuid.UnparseText()); + return false; + } + const CameraData &data = it->second; VLOG(1) << "...(2/4): Querying database"; - auto run = ctx.Borrow(&build_mp4_query_); - run.BindInt64(":camera_id", camera_id); + auto run = ctx.Borrow(&build_mp4_stmt_); + run.BindInt64(":camera_id", data.id); run.BindInt64(":end_time_90k", end_time_90k); run.BindInt64(":start_time_90k", start_time_90k); Recording recording; VideoSampleEntry sample_entry; while (run.Step() == SQLITE_ROW) { - recording.rowid = run.ColumnInt64(0); + recording.id = run.ColumnInt64(0); + recording.camera_id = data.id; recording.start_time_90k = run.ColumnInt64(1); - recording.end_time_90k = run.ColumnInt64(2); + recording.end_time_90k = recording.start_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 ", + StrCat("recording ", recording.id, " has unparseable uuid ", ToHex(run.ColumnBlob(4))); return false; } @@ -262,19 +418,23 @@ bool MoonfireDatabase::ListMp4Recordings( 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); + recording.video_index = run.ColumnBlob(6).as_string(); + recording.video_samples = run.ColumnInt64(7); + recording.video_sync_samples = run.ColumnInt64(8); + recording.video_sample_entry_id = run.ColumnInt64(9); - if (recording.video_sample_entry_sha1 != sample_entry.sha1) { - 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); + auto it = video_sample_entries_.find(recording.video_sample_entry_id); + if (it == video_sample_entries_.end()) { + *error_message = StrCat("recording ", recording.id, + " references unknown video sample entry ", + recording.video_sample_entry_id); + return false; } + const VideoSampleEntry &entry = it->second; - row_cb(recording, sample_entry); + if (row_cb(recording, entry) == IterationControl::kBreak) { + return true; + } } if (run.status() != SQLITE_DONE && run.status() != SQLITE_ROW) { *error_message = StrCat("sqlite query failed: ", run.error_message()); @@ -283,10 +443,30 @@ bool MoonfireDatabase::ListMp4Recordings( return true; } +bool MoonfireDatabase::ListReservedSampleFiles(std::vector *reserved, + std::string *error_message) { + reserved->clear(); + DatabaseContext ctx(db_); + auto run = ctx.UseOnce("select uuid from reserved_sample_files;"); + while (run.Step() == SQLITE_ROW) { + Uuid uuid; + if (!uuid.ParseBinary(run.ColumnBlob(0))) { + *error_message = StrCat("unparseable uuid ", ToHex(run.ColumnBlob(0))); + return false; + } + reserved->push_back(uuid); + } + if (run.status() != SQLITE_DONE) { + *error_message = 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, + Uuid camera_uuid, int64_t start_time_90k, int64_t end_time_90k, std::string *error_message) { - LOG(INFO) << "Building mp4 for camera: " << camera_id + LOG(INFO) << "Building mp4 for camera: " << camera_uuid.UnparseText() << ", start_time_90k: " << start_time_90k << ", end_time_90k: " << end_time_90k; @@ -315,11 +495,11 @@ std::shared_ptr MoonfireDatabase::BuildMp4( next_row_start_time_90k = recording.end_time_90k; - if (rows > 0 && recording.video_sample_entry_sha1 != sample_entry.sha1) { + if (rows > 0 && recording.video_sample_entry_id != sample_entry.id) { *error_message = - StrCat("inconsistent video sample entries: this recording has ", - ToHex(recording.video_sample_entry_sha1), ", previous had ", - ToHex(sample_entry.sha1)); + StrCat("inconsistent video sample entries: this recording has id ", + recording.video_sample_entry_id, " previous had ", + sample_entry.id, " (sha1 ", ToHex(sample_entry.sha1), ")"); ok = false; return IterationControl::kBreak; } else if (rows == 0) { @@ -334,7 +514,7 @@ std::shared_ptr MoonfireDatabase::BuildMp4( return IterationControl::kContinue; }; if (!ok || - !ListMp4Recordings(camera_id, start_time_90k, end_time_90k, row_cb, + !ListMp4Recordings(camera_uuid, start_time_90k, end_time_90k, row_cb, error_message)) { return false; } @@ -362,4 +542,329 @@ std::shared_ptr MoonfireDatabase::BuildMp4( return file; } +std::vector MoonfireDatabase::ReserveSampleFiles( + int n, std::string *error_message) { + if (n == 0) { + return std::vector(); + } + auto *gen = GetRealUuidGenerator(); + std::vector uuids; + uuids.reserve(n); + for (int i = 0; i < n; ++i) { + uuids.push_back(gen->Generate()); + } + DatabaseContext ctx(db_); + if (!ctx.BeginTransaction(error_message)) { + return std::vector(); + } + for (const auto &uuid : uuids) { + auto run = ctx.Borrow(&insert_reservation_stmt_); + run.BindBlob(":uuid", uuid.binary_view()); + run.BindInt64(":state", static_cast(ReservationState::kWriting)); + if (run.Step() != SQLITE_DONE) { + ctx.RollbackTransaction(); + *error_message = run.error_message(); + return std::vector(); + } + } + if (!ctx.CommitTransaction(error_message)) { + return std::vector(); + } + return uuids; +} + +bool MoonfireDatabase::InsertVideoSampleEntry(VideoSampleEntry *entry, + std::string *error_message) { + if (entry->id != -1) { + *error_message = StrCat("video_sample_entry already has id ", entry->id); + return false; + } + DatabaseContext ctx(db_); + for (const auto &some_entry : video_sample_entries_) { + if (some_entry.second.sha1 == entry->sha1) { + if (entry->width != some_entry.second.width || + entry->height != some_entry.second.height) { + *error_message = + StrCat("inconsistent entry for sha1 ", ToHex(entry->sha1), + ": existing entry has ", some_entry.second.width, "x", + some_entry.second.height, ", new entry has ", entry->width, + "x", entry->height); + return false; + } + entry->id = some_entry.first; + return true; + } + } + auto insert_run = ctx.Borrow(&insert_video_sample_entry_stmt_); + insert_run.BindBlob(":sha1", entry->sha1); + insert_run.BindInt64(":width", entry->width); + insert_run.BindInt64(":height", entry->height); + insert_run.BindBlob(":data", entry->data); + if (insert_run.Step() != SQLITE_DONE) { + *error_message = insert_run.error_message(); + return false; + } + entry->id = ctx.last_insert_rowid(); + CHECK(video_sample_entries_.insert(std::make_pair(entry->id, *entry)).second) + << "duplicate: " << entry->id; + return true; +} + +bool MoonfireDatabase::InsertRecording(Recording *recording, + std::string *error_message) { + if (recording->id != -1) { + *error_message = StrCat("recording already has id ", recording->id); + return false; + } + if (recording->end_time_90k <= recording->start_time_90k) { + *error_message = + StrCat("end time ", recording->end_time_90k, + " must be greater than start time ", recording->start_time_90k); + return false; + } + DatabaseContext ctx(db_); + auto it = cameras_by_id_.find(recording->camera_id); + if (it == cameras_by_id_.end()) { + *error_message = StrCat("no camera with id ", recording->camera_id); + return false; + } + CameraData *camera_data = it->second; + if (!ctx.BeginTransaction(error_message)) { + return false; + } + auto delete_run = ctx.Borrow(&delete_reservation_stmt_); + delete_run.BindBlob(":uuid", recording->sample_file_uuid.binary_view()); + if (delete_run.Step() != SQLITE_DONE) { + *error_message = delete_run.error_message(); + ctx.RollbackTransaction(); + return false; + } + if (ctx.changes() != 1) { + *error_message = StrCat("uuid ", recording->sample_file_uuid.UnparseText(), + " is not reserved"); + ctx.RollbackTransaction(); + return false; + } + auto insert_run = ctx.Borrow(&insert_recording_stmt_); + insert_run.BindInt64(":camera_id", recording->camera_id); + insert_run.BindInt64(":sample_file_bytes", recording->sample_file_bytes); + insert_run.BindInt64(":start_time_90k", recording->start_time_90k); + insert_run.BindInt64(":duration_90k", + recording->end_time_90k - recording->start_time_90k); + insert_run.BindInt64(":video_samples", recording->video_samples); + insert_run.BindInt64(":video_sync_samples", recording->video_sync_samples); + insert_run.BindInt64(":video_sample_entry_id", + recording->video_sample_entry_id); + insert_run.BindBlob(":sample_file_uuid", + recording->sample_file_uuid.binary_view()); + insert_run.BindBlob(":sample_file_sha1", recording->sample_file_sha1); + insert_run.BindBlob(":video_index", recording->video_index); + if (insert_run.Step() != SQLITE_DONE) { + LOG(ERROR) << "insert_run failed: " << insert_run.error_message(); + *error_message = insert_run.error_message(); + ctx.RollbackTransaction(); + return false; + } + if (!ctx.CommitTransaction(error_message)) { + LOG(ERROR) << "commit failed"; + return false; + } + recording->id = ctx.last_insert_rowid(); + if (camera_data->min_start_time_90k == -1 || + camera_data->min_start_time_90k > recording->start_time_90k) { + camera_data->min_start_time_90k = recording->start_time_90k; + } + if (camera_data->max_end_time_90k == -1 || + camera_data->max_end_time_90k < recording->end_time_90k) { + camera_data->max_end_time_90k = recording->end_time_90k; + } + camera_data->total_duration_90k += + recording->end_time_90k - recording->start_time_90k; + camera_data->total_sample_file_bytes += recording->sample_file_bytes; + return true; +} + +bool MoonfireDatabase::ListOldestSampleFiles( + Uuid camera_uuid, + std::function row_cb, + std::string *error_message) { + DatabaseContext ctx(db_); + auto it = cameras_by_uuid_.find(camera_uuid); + if (it == cameras_by_uuid_.end()) { + *error_message = StrCat("no such camera ", camera_uuid.UnparseText()); + return false; + } + const CameraData &camera_data = it->second; + auto run = ctx.Borrow(&list_oldest_sample_files_stmt_); + run.BindInt64(":camera_id", camera_data.id); + ListOldestSampleFilesRow row; + while (run.Step() == SQLITE_ROW) { + row.camera_id = camera_data.id; + row.recording_id = run.ColumnInt64(0); + if (!row.sample_file_uuid.ParseBinary(run.ColumnBlob(1))) { + *error_message = + StrCat("recording ", row.recording_id, " has unparseable uuid ", + ToHex(run.ColumnBlob(1))); + return false; + } + row.duration_90k = run.ColumnInt64(2); + row.sample_file_bytes = run.ColumnInt64(3); + if (row_cb(row) == IterationControl::kBreak) { + return true; + } + } + if (run.status() != SQLITE_DONE) { + *error_message = run.error_message(); + return false; + } + return true; +} + +bool MoonfireDatabase::DeleteRecordings( + const std::vector &recordings, + std::string *error_message) { + if (recordings.empty()) { + return true; + } + + DatabaseContext ctx(db_); + if (!ctx.BeginTransaction(error_message)) { + return false; + } + struct State { + int64_t deleted_duration_90k = 0; + int64_t deleted_sample_file_bytes = 0; + int64_t min_start_time_90k = -1; + int64_t max_end_time_90k = -1; + CameraData *camera_data = nullptr; + }; + std::map state_by_camera_id; + for (const auto &recording : recordings) { + State &state = state_by_camera_id[recording.camera_id]; + state.deleted_duration_90k += recording.duration_90k; + state.deleted_sample_file_bytes += recording.sample_file_bytes; + + auto delete_run = ctx.Borrow(&delete_recording_stmt_); + delete_run.BindInt64(":recording_id", recording.recording_id); + if (delete_run.Step() != SQLITE_DONE) { + ctx.RollbackTransaction(); + *error_message = StrCat("delete: ", delete_run.error_message()); + return false; + } + if (ctx.changes() != 1) { + ctx.RollbackTransaction(); + *error_message = StrCat("no such recording ", recording.recording_id); + return false; + } + + auto insert_run = ctx.Borrow(&insert_reservation_stmt_); + insert_run.BindBlob(":uuid", recording.sample_file_uuid.binary_view()); + insert_run.BindInt64(":state", + static_cast(ReservationState::kDeleting)); + if (insert_run.Step() != SQLITE_DONE) { + ctx.RollbackTransaction(); + *error_message = StrCat("insert: ", insert_run.error_message()); + return false; + } + } + + // Recompute start and end times for each camera. + for (auto &state_entry : state_by_camera_id) { + int64_t camera_id = state_entry.first; + State &state = state_entry.second; + auto it = cameras_by_id_.find(camera_id); + if (it == cameras_by_id_.end()) { + *error_message = + StrCat("internal error; can't find camera id ", camera_id); + return false; + } + state.camera_data = it->second; + + // The minimum is straightforward, taking advantage of the start_time_90k + // index for speed. + auto min_run = ctx.Borrow(&camera_min_start_stmt_); + min_run.BindInt64(":camera_id", camera_id); + if (min_run.Step() == SQLITE_ROW) { + state.min_start_time_90k = min_run.ColumnInt64(0); + } else if (min_run.Step() == SQLITE_DONE) { + // There are no recordings left. + state.min_start_time_90k = -1; + state.max_end_time_90k = -1; + continue; // skip additional query below to calculate max. + } else { + ctx.RollbackTransaction(); + *error_message = StrCat("min: ", min_run.error_message()); + return false; + } + + // The maximum is less straightforward in the case of overlap - all + // recordings starting in the last kMaxRecordingDuration must be examined + // to take advantage of the start_time_90k index. + auto max_run = ctx.Borrow(&camera_max_start_stmt_); + max_run.BindInt64(":camera_id", camera_id); + if (max_run.Step() != SQLITE_ROW) { + // If there was a min row, there should be a max row too, so this is an + // error even in the SQLITE_DONE case. + ctx.RollbackTransaction(); + *error_message = StrCat("max[0]: ", max_run.error_message()); + return false; + } + int64_t max_start_90k = max_run.ColumnInt64(0); + do { + auto end_time_90k = max_run.ColumnInt64(0) + max_run.ColumnInt64(1); + state.max_end_time_90k = std::max(state.max_end_time_90k, end_time_90k); + } while (max_run.Step() == SQLITE_ROW && + max_run.ColumnInt64(0) > max_start_90k - kMaxRecordingDuration); + if (max_run.status() != SQLITE_DONE && max_run.status() != SQLITE_ROW) { + *error_message = StrCat("max[1]: ", max_run.error_message()); + ctx.RollbackTransaction(); + return false; + } + } + + if (!ctx.CommitTransaction(error_message)) { + *error_message = StrCat("commit: ", *error_message); + return false; + } + + for (auto &state_entry : state_by_camera_id) { + State &state = state_entry.second; + state.camera_data->total_duration_90k -= state.deleted_duration_90k; + state.camera_data->total_sample_file_bytes -= + state.deleted_sample_file_bytes; + state.camera_data->min_start_time_90k = state.min_start_time_90k; + state.camera_data->max_end_time_90k = state.max_end_time_90k; + } + return true; +} + +bool MoonfireDatabase::MarkSampleFilesDeleted(const std::vector &uuids, + std::string *error_message) { + if (uuids.empty()) { + return true; + } + DatabaseContext ctx(db_); + if (!ctx.BeginTransaction(error_message)) { + return false; + } + for (const auto &uuid : uuids) { + auto run = ctx.Borrow(&delete_reservation_stmt_); + run.BindBlob(":uuid", uuid.binary_view()); + if (run.Step() != SQLITE_DONE) { + *error_message = run.error_message(); + ctx.RollbackTransaction(); + return false; + } + if (ctx.changes() != 1) { + *error_message = StrCat("no reservation for uuid ", uuid.UnparseText()); + ctx.RollbackTransaction(); + return false; + } + } + if (!ctx.CommitTransaction(error_message)) { + return false; + } + return true; +} + } // namespace moonfire_nvr diff --git a/src/moonfire-db.h b/src/moonfire-db.h index 5d57dd1..4b020e4 100644 --- a/src/moonfire-db.h +++ b/src/moonfire-db.h @@ -32,18 +32,30 @@ // 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: +// This caches data in RAM, making the assumption that only one process is +// accessing the database at a time. (TODO: enforce with flock or some such.) +// Performance and efficiency notes: // -// * 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. +// * several query operations here feature row callbacks. The callback is +// invoked with the database lock. Thus, the caller mustn't perform database +// operations or other long-running operations. +// +// * startup may be slow, as it scans the entire index for the recording +// table. This seems acceptable. +// +// * the operations used for web file serving should return results with +// acceptable latency. +// +// * however, the database lock may be held for longer than is acceptable for +// the critical path of recording frames. It may be necessary to preallocate +// sample file uuids and such to avoid this. +// +// * the caller may need to perform several different types of write +// operations in a row. It might be worth creating an interface for batching +// these inside a transaction, to reduce latency and SSD write cycles. The +// pre-commit and post-commit logic of each operation would have to be +// pulled apart, with the latter being called by this wrapper class on +// commit of the overall transaction. #ifndef MOONFIRE_NVR_MOONFIRE_DB_H #define MOONFIRE_NVR_MOONFIRE_DB_H @@ -51,6 +63,7 @@ #include #include #include +#include #include "common.h" #include "http.h" @@ -62,28 +75,32 @@ 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; + // Aggregates summarizing completed recordings. + int64_t min_start_time_90k = -1; + int64_t max_end_time_90k = -1; + int64_t total_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. +// This includes everything in ListCamerasRow. In the future, it will include +// more data. Likely, that will mean a list of calendar days (in the system +// time zone) in which there is any data. struct GetCameraRow { - int64_t retain_bytes = -1; - Uuid uuid; std::string short_name; std::string description; + int64_t retain_bytes = -1; + int64_t min_start_time_90k = -1; + int64_t max_end_time_90k = -1; + int64_t total_duration_90k = -1; + int64_t total_sample_file_bytes = -1; + + // TODO: std::vector days; // keys: YYYY-mm-dd. }; // For use with MoonfireDatabase::ListCameraRecordings. @@ -93,53 +110,137 @@ struct ListCameraRecordingsRow { 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; + // |video_sample_entry_sha1| is valid as long as the MoonfireDatabase. + re2::StringPiece video_sample_entry_sha1; + uint16_t width = 0; + uint16_t height = 0; }; +// For use with MoonfireDatabase::ListOldestSampleFiles. +struct ListOldestSampleFilesRow { + int64_t camera_id = -1; + int64_t recording_id = -1; + Uuid sample_file_uuid; + int64_t duration_90k = -1; + int64_t sample_file_bytes = -1; +}; + +// Thread-safe after Init. +// (Uses a DatabaseContext for locking.) class MoonfireDatabase { public: - explicit MoonfireDatabase(Database *db) : db_(db) {} + MoonfireDatabase() {} MoonfireDatabase(const MoonfireDatabase &) = delete; void operator=(const MoonfireDatabase &) = delete; - bool Init(std::string *error_message); + // |db| must outlive the MoonfireDatabase. + bool Init(Database *db, 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); + void ListCameras(std::function cb); - bool GetCamera(int64_t camera_id, GetCameraRow *row, - std::string *error_message); + // Get a single camera. + // Return true iff the camera exists. + bool GetCamera(Uuid camera_uuid, GetCameraRow *row); - // List all recordings associated with a camera, ordered by start time.. - // Holds database lock; callback should be quick. + // List all recordings associated with a camera, descending by end time. bool ListCameraRecordings( - int64_t camera_id, + Uuid camera_uuid, int64_t start_time_90k, int64_t end_time_90k, std::function, std::string *error_message); bool ListMp4Recordings( - int64_t camera_id, int64_t start_time_90k, int64_t end_time_90k, + Uuid camera_uuid, int64_t start_time_90k, int64_t end_time_90k, std::function row_cb, std::string *error_message); - std::shared_ptr BuildMp4(int64_t camera_id, + // TODO: more nuanced error code for HTTP. + // TODO: this should move somewhere that has access to the + // currently-writing Recording as well. + std::shared_ptr BuildMp4(Uuid camera_uuid, int64_t start_time_90k, int64_t end_time_90k, std::string *error_message); + bool ListReservedSampleFiles(std::vector *reserved, + std::string *error_message); + + // Reserve |n| new sample file uuids. + // Returns an empty vector on error. + std::vector ReserveSampleFiles(int n, std::string *error_message); + + // Insert a video sample entry if not already inserted. + // On success, |entry->id| is filled in with the id of a freshly-created or + // existing row. + bool InsertVideoSampleEntry(VideoSampleEntry *entry, + std::string *error_message); + + // Insert a new recording. + // The uuid must have been already reserved with ReserveSampleFileUuid above. + // On success, |recording->id| is filled in. + bool InsertRecording(Recording *recording, std::string *error_message); + + // List sample files, starting from the oldest. + // The caller is expected to supply a |row_cb| that returns kBreak when + // enough have been listed. + bool ListOldestSampleFiles( + Uuid camera_uuid, + std::function row_cb, + std::string *error_message); + + // Delete recording rows, moving their sample file uuids to the deleting + // state. + bool DeleteRecordings(const std::vector &rows, + std::string *error_message); + + // Mark a set of sample files as deleted. + // This shouldn't be called until the files have been unlinke()ed and the + // parent directory fsync()ed. + // Returns error if any sample files are not in the deleting state. + bool MarkSampleFilesDeleted(const std::vector &uuids, + 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_; + struct CameraData { + // Cached values of the matching fields from the camera row. + int64_t id = -1; + std::string short_name; + std::string description; + int64_t retain_bytes = -1; + + // Aggregates of all recordings associated with the camera. + int64_t min_start_time_90k = -1; + int64_t max_end_time_90k = -1; + int64_t total_sample_file_bytes = -1; + int64_t total_duration_90k = -1; + }; + + enum class ReservationState { kWriting = 0, kDeleting = 1 }; + + // Efficiently (re-)compute the bounds of recorded time for a given camera. + bool ComputeCameraRecordingBounds(DatabaseContext *ctx, int64_t camera_id, + int64_t *min_start_time_90k, + int64_t *max_end_time_90k, + std::string *error_message); + + Database *db_ = nullptr; + Statement list_camera_recordings_stmt_; + Statement build_mp4_stmt_; + Statement insert_reservation_stmt_; + Statement delete_reservation_stmt_; + Statement insert_video_sample_entry_stmt_; + Statement insert_recording_stmt_; + Statement list_oldest_sample_files_stmt_; + Statement delete_recording_stmt_; + Statement camera_min_start_stmt_; + Statement camera_max_start_stmt_; + + std::map cameras_by_uuid_; + std::map cameras_by_id_; + std::map video_sample_entries_; }; } // namespace moonfire_nvr diff --git a/src/moonfire-nvr-test.cc b/src/moonfire-nvr-test.cc index 02dd813..d4b9cdd 100644 --- a/src/moonfire-nvr-test.cc +++ b/src/moonfire-nvr-test.cc @@ -148,7 +148,7 @@ class StreamTest : public testing::Test { env_.clock = &clock_; env_.video_source = &video_source_; env_.fs = GetRealFilesystem(); - clock_.Sleep({1430006400, 0}); // 2016-04-26 00:00:00 UTC + clock_.Sleep({1430006400, 0}); // 2015-04-26 00:00:00 UTC config_.set_base_path(test_dir_); config_.set_rotate_sec(5); diff --git a/src/mp4.cc b/src/mp4.cc index 34261b7..6e08120 100644 --- a/src/mp4.cc +++ b/src/mp4.cc @@ -727,12 +727,11 @@ Mp4FileBuilder &Mp4FileBuilder::SetSampleEntry(const VideoSampleEntry &entry) { std::shared_ptr Mp4FileBuilder::Build(std::string *error_message) { int32_t sample_offset = 1; for (auto &segment : segments_) { - if (segment->recording.video_sample_entry_sha1 != - video_sample_entry_.sha1) { - *error_message = - StrCat("inconsistent video sample entries. builder has: ", - ToHex(video_sample_entry_.sha1), ", segment has: ", - ToHex(segment->recording.video_sample_entry_sha1)); + if (segment->recording.video_sample_entry_id != video_sample_entry_.id) { + *error_message = StrCat( + "inconsistent video sample entries. builder has: ", + video_sample_entry_.id, " (sha1 ", ToHex(video_sample_entry_.sha1), + ", segment has: ", segment->recording.video_sample_entry_id); return std::shared_ptr(); } diff --git a/src/recording.h b/src/recording.h index 13fa8f3..a60f858 100644 --- a/src/recording.h +++ b/src/recording.h @@ -55,16 +55,19 @@ constexpr int64_t kTimeUnitsPerSecond = 90000; // This limit should be more than the normal rotation time, // as recording doesn't happen until the next key frame. // 5 minutes is generously more than 1 minute, but still sufficient to -// allow the optimization to be useful. +// allow the optimization to be useful. This value must match the CHECK +// constraint on duration_90k in schema.sql. constexpr int64_t kMaxRecordingDuration = 5 * 60 * kTimeUnitsPerSecond; // Various fields from the "recording" table which are useful when viewing // recordings. struct Recording { - int64_t rowid = -1; + int64_t id = -1; + int64_t camera_id = -1; std::string sample_file_path; std::string sample_file_sha1; Uuid sample_file_uuid; + int64_t video_sample_entry_id = -1; // Fields populated by SampleIndexEncoder. int64_t start_time_90k = -1; @@ -72,7 +75,6 @@ struct Recording { int64_t sample_file_bytes = -1; int64_t video_samples = -1; int64_t video_sync_samples = -1; - std::string video_sample_entry_sha1; std::string video_index; }; @@ -196,6 +198,7 @@ class SampleFileWriter { }; struct VideoSampleEntry { + int64_t id = -1; std::string sha1; std::string data; uint16_t width = 0; diff --git a/src/schema.sql b/src/schema.sql index d4672d5..241cf3f 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -31,14 +31,14 @@ -- schema.sql: SQLite3 database schema for Moonfire NVR. -- See also design/schema.md. -pragma journal_mode = wal; +--pragma journal_mode = wal; create table camera ( id integer primary key, - uuid blob unique not null, + uuid blob unique,-- not null check (length(uuid) = 16), -- A short name of the camera, used in log messages. - short_name text not null, + short_name text,-- not null, -- A short description of the camera. description text, @@ -63,45 +63,70 @@ create table camera ( -- The number of bytes of video to retain, excluding the currently-recording -- file. Older files will be deleted as necessary to stay within this limit. - retain_bytes integer + retain_bytes integer not null check (retain_bytes >= 0) ); --- A single, typically 60-second, recorded segment of video. +-- Each row represents a single completed recorded segment of video. +-- Recordings are typically ~60 seconds; never more than 5 minutes. create table recording ( id integer primary key, camera_id integer references camera (id) not null, - status integer not null, -- 0 (WRITING), 1 (WRITTEN), or 2 (DELETING) + sample_file_bytes integer not null check (sample_file_bytes > 0), - sample_file_uuid blob unique not null, - sample_file_sha1 blob, - sample_file_bytes integer, - - -- The starting and ending time of the recording, in 90 kHz units since + -- The starting time of the recording, in 90 kHz units since -- 1970-01-01 00:00:00 UTC. - start_time_90k integer not null, - end_time_90k integer, + start_time_90k integer not null check (start_time_90k > 0), - video_samples integer, - video_sync_samples integer, - video_sample_entry_sha1 blob references video_sample_entry (sha1), - video_index blob + -- The duration of the recording, in 90 kHz units. + duration_90k integer not null + check (duration_90k >= 0 and duration_90k < 5*60*90000), + + video_samples integer not null check (video_samples > 0), + video_sync_samples integer not null check (video_samples > 0), + video_sample_entry_id integer references video_sample_entry (id), + + sample_file_uuid blob not null check (length(sample_file_uuid) = 16), + sample_file_sha1 blob not null check (length(sample_file_sha1) = 20), + video_index blob not null check (length(video_index) > 0) ); -create index recording_start_time_90k on recording (start_time_90k); +create index recording_cover on recording ( + -- Typical queries use "where camera_id = ? order by start_time_90k (desc)?". + camera_id, + start_time_90k, + + -- These fields are not used for ordering; they cover most queries so + -- that only database verification and actual viewing of recordings need + -- to consult the underlying row. + duration_90k, + video_samples, + video_sample_entry_id, + sample_file_bytes +); + +-- Files in the sample file directory which may be present but should simply be +-- discarded on startup. (Recordings which were never completed or have been +-- marked for completion.) +create table reserved_sample_files ( + uuid blob primary key check (length(uuid) = 16), + state integer not null -- 0 (writing) or 1 (deleted) +) without rowid; -- A concrete box derived from a ISO/IEC 14496-12 section 8.5.2 -- VisualSampleEntry box. Describes the codec, width, height, etc. create table video_sample_entry ( + id integer primary key, + -- A SHA-1 hash of |bytes|. - sha1 blob primary key, + sha1 blob unique not null check (length(sha1) = 20), -- The width and height in pixels; must match values within -- |sample_entry_bytes|. - width integer, - height integer, + width integer not null check (width > 0), + height integer not null check (height > 0), - -- A serialized SampleEntry box, including the leading length and box - -- type (avcC in the case of H.264). - bytes blob + -- The serialized box, including the leading length and box type (avcC in + -- the case of H.264). + data blob not null check (length(data) > 86) ); diff --git a/src/sqlite-test.cc b/src/sqlite-test.cc index d727b55..20834c8 100644 --- a/src/sqlite-test.cc +++ b/src/sqlite-test.cc @@ -78,7 +78,7 @@ TEST_F(SqliteTest, BindAndColumn) { 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; re2::StringPiece blob_piece = re2::StringPiece(kBlob, sizeof(kBlob)); const char kText[] = "foo"; - const int64_t kInt64 = INT64_C(0xdeadbeeffeedface); + const int64_t kInt64 = INT64_C(0xbeeffeedface); DatabaseContext ctx(&db_); { diff --git a/src/sqlite.cc b/src/sqlite.cc index beca66c..d3108cd 100644 --- a/src/sqlite.cc +++ b/src/sqlite.cc @@ -88,7 +88,8 @@ bool DatabaseContext::BeginTransaction(std::string *error_message) { sqlite3_step(db_->begin_transaction_.me_); int ret = sqlite3_reset(db_->begin_transaction_.me_); if (ret != SQLITE_OK) { - *error_message = sqlite3_errstr(ret); + *error_message = + StrCat("begin transaction: ", sqlite3_errstr(ret), " (", ret, ")"); return false; } transaction_open_ = true; @@ -103,7 +104,8 @@ bool DatabaseContext::CommitTransaction(std::string *error_message) { sqlite3_step(db_->commit_transaction_.me_); int ret = sqlite3_reset(db_->commit_transaction_.me_); if (ret != SQLITE_OK) { - *error_message = sqlite3_errstr(ret); + *error_message = + StrCat("commit transaction: ", sqlite3_errstr(ret), " (", ret, ")"); return false; } transaction_open_ = false; @@ -118,7 +120,8 @@ void DatabaseContext::RollbackTransaction() { sqlite3_step(db_->rollback_transaction_.me_); int ret = sqlite3_reset(db_->rollback_transaction_.me_); if (ret != SQLITE_OK) { - LOG(WARNING) << this << ": rollback failed: " << sqlite3_errstr(ret); + LOG(WARNING) << this << ": rollback failed: " << sqlite3_errstr(ret) << " (" + << ret << ")"; return; } transaction_open_ = false; @@ -137,26 +140,36 @@ RunningStatement DatabaseContext::UseOnce(re2::StringPiece sql) { RunningStatement::RunningStatement(Statement *statement, const std::string &deferred_error, bool owns_statement) - : statement_(statement), - error_message_(deferred_error), - owns_statement_(owns_statement) { - CHECK(!statement->borrowed_) << "Statement already borrowed!"; - statement->borrowed_ = true; + : error_message_(deferred_error), owns_statement_(owns_statement) { + if (statement != nullptr && statement->valid()) { + CHECK(!statement->borrowed_) << "Statement already borrowed!"; + statement->borrowed_ = true; + statement_ = statement; + } else if (error_message_.empty()) { + error_message_ = "invalid statement"; + } + if (!error_message_.empty()) { status_ = SQLITE_MISUSE; - } else if (statement == nullptr) { - status_ = SQLITE_MISUSE; - error_message_ = "invalid statement"; } } +RunningStatement::RunningStatement(RunningStatement &&o) { + statement_ = o.statement_; + status_ = o.status_; + owns_statement_ = o.owns_statement_; + o.statement_ = nullptr; +} + RunningStatement::~RunningStatement() { - CHECK(statement_->borrowed_) << "Statement no longer borrowed!"; - sqlite3_reset(statement_->me_); - sqlite3_clear_bindings(statement_->me_); - statement_->borrowed_ = false; - if (owns_statement_) { - delete statement_; + if (statement_ != nullptr) { + CHECK(statement_->borrowed_) << "Statement no longer borrowed!"; + sqlite3_clear_bindings(statement_->me_); + sqlite3_reset(statement_->me_); + statement_->borrowed_ = false; + if (owns_statement_) { + delete statement_; + } } } @@ -168,7 +181,7 @@ void RunningStatement::BindBlob(int param, re2::StringPiece value) { value.size(), SQLITE_TRANSIENT); if (status_ != SQLITE_OK) { error_message_ = StrCat("Unable to bind parameter ", param, ": ", - sqlite3_errstr(status_)); + sqlite3_errstr(status_), " (", status_, ")"); } } @@ -186,7 +199,7 @@ void RunningStatement::BindBlob(const char *name, re2::StringPiece value) { value.size(), SQLITE_TRANSIENT); if (status_ != SQLITE_OK) { error_message_ = StrCat("Unable to bind parameter ", name, ": ", - sqlite3_errstr(status_)); + sqlite3_errstr(status_), " (", status_, ")"); } } @@ -197,7 +210,7 @@ void RunningStatement::BindInt64(int param, int64_t value) { status_ = sqlite3_bind_int64(statement_->me_, param, value); if (status_ != SQLITE_OK) { error_message_ = StrCat("Unable to bind parameter ", param, ": ", - sqlite3_errstr(status_)); + sqlite3_errstr(status_), " (", status_, ")"); } } @@ -214,7 +227,7 @@ void RunningStatement::BindInt64(const char *name, int64_t value) { status_ = sqlite3_bind_int64(statement_->me_, param, value); if (status_ != SQLITE_OK) { error_message_ = StrCat("Unable to bind parameter ", name, ": ", - sqlite3_errstr(status_)); + sqlite3_errstr(status_), " (", status_, ")"); } } @@ -226,7 +239,7 @@ void RunningStatement::BindText(int param, re2::StringPiece value) { value.size(), SQLITE_TRANSIENT, SQLITE_UTF8); if (status_ != SQLITE_OK) { error_message_ = StrCat("Unable to bind parameter ", param, ": ", - sqlite3_errstr(status_)); + sqlite3_errstr(status_), " (", status_, ")"); } } @@ -243,7 +256,7 @@ void RunningStatement::BindText(const char *name, re2::StringPiece value) { value.size(), SQLITE_TRANSIENT, SQLITE_UTF8); if (status_ != SQLITE_OK) { error_message_ = StrCat("Unable to bind parameter ", name, ": ", - sqlite3_errstr(status_)); + sqlite3_errstr(status_), " (", status_, ")"); } } @@ -252,7 +265,8 @@ int RunningStatement::Step() { return status_; } status_ = sqlite3_step(statement_->me_); - error_message_ = sqlite3_errstr(status_); + error_message_ = + StrCat("step: ", sqlite3_errstr(status_), " (", status_, ")"); return status_; } @@ -291,7 +305,8 @@ bool Database::Open(const char *filename, int flags, std::call_once(global_setup, &GlobalSetup); int ret = sqlite3_open_v2(filename, &me_, flags, nullptr); if (ret != SQLITE_OK) { - *error_message = sqlite3_errstr(ret); + *error_message = + StrCat("open ", filename, ": ", sqlite3_errstr(ret), " (", ret, ")"); return false; } @@ -299,8 +314,8 @@ bool Database::Open(const char *filename, int flags, if (ret != SQLITE_OK) { sqlite3_close(me_); me_ = nullptr; - *error_message = - StrCat("while enabling extended result codes: ", sqlite3_errstr(ret)); + *error_message = StrCat("while enabling extended result codes: ", + sqlite3_errstr(ret), " (", ret, ")"); return false; } @@ -331,8 +346,8 @@ bool Database::Open(const char *filename, int flags, if (ret != SQLITE_DONE) { sqlite3_close(me_); me_ = nullptr; - *error_message = - StrCat("while enabling foreign keys: ", sqlite3_errstr(ret)); + *error_message = StrCat("while enabling foreign keys: ", + sqlite3_errstr(ret), " (", ret, ")"); return false; } @@ -346,7 +361,7 @@ Statement Database::Prepare(re2::StringPiece sql, size_t *used, int err = sqlite3_prepare_v2(me_, sql.data(), sql.size(), &statement.me_, &tail); if (err != SQLITE_OK) { - *error_message = sqlite3_errstr(err); + *error_message = StrCat("prepare: ", sqlite3_errstr(err), " (", err, ")"); return statement; } if (used != nullptr) { diff --git a/src/sqlite.h b/src/sqlite.h index 3759dee..df82061 100644 --- a/src/sqlite.h +++ b/src/sqlite.h @@ -141,7 +141,7 @@ class Database { // } class RunningStatement { public: - RunningStatement(RunningStatement &&) = default; + RunningStatement(RunningStatement &&o); // Reset/unbind/return the statement for the next use (in the case of // Borrow) or delete it (in the case of UseOnce). diff --git a/src/time.h b/src/time.h index effc04c..ec04e98 100644 --- a/src/time.h +++ b/src/time.h @@ -66,6 +66,10 @@ inline struct timespec SecToTimespec(double sec) { return {static_cast(intpart), static_cast(fractpart * kNanos)}; } +inline double TimespecToSec(struct timespec t) { + return t.tv_sec + static_cast(t.tv_nsec) / kNanos; +} + // Returns the real wall clock, which will never be deleted. WallClock *GetRealClock(); diff --git a/src/uuid.h b/src/uuid.h index 068bac2..4c3f255 100644 --- a/src/uuid.h +++ b/src/uuid.h @@ -61,6 +61,8 @@ class Uuid { bool operator==(const Uuid &) const; bool operator<(const Uuid &) const; + bool is_null() const { return uuid_is_null(me_); } + private: friend class RealUuidGenerator; uuid_t me_; diff --git a/src/web.cc b/src/web.cc index 32c4d59..8aaa08c 100644 --- a/src/web.cc +++ b/src/web.cc @@ -62,34 +62,35 @@ void WebInterface::HandleCameraList(evhttp_request *req, void *arg) { "\n"); auto row_cb = [&](const ListCamerasRow &row) { auto seconds = - (row.max_recording_end_time_90k - row.min_recording_start_time_90k) / - kTimeUnitsPerSecond; + (row.max_end_time_90k - row.min_start_time_90k) / kTimeUnitsPerSecond; + std::string min_start_time_90k = + row.min_start_time_90k == -1 ? std::string("n/a") + : PrettyTimestamp(row.min_start_time_90k); + std::string max_end_time_90k = row.max_end_time_90k == -1 + ? std::string("n/a") + : PrettyTimestamp(row.max_end_time_90k); buf.AddPrintf( - "\n" + "\n" "\n" "\n" "\n" "\n" "\n" "\n", - row.id, EscapeHtml(row.short_name).c_str(), + row.uuid.UnparseText().c_str(), 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(min_start_time_90k).c_str(), + EscapeHtml(max_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()); - } + this_->mdb_->ListCameras(row_cb); buf.Add( "
%s
%s" + "
description%s
space%s / %s (%.1f%%)
uuid%s
oldest recording%s
newest recording%s
total duration%s
\n" "\n" @@ -100,19 +101,15 @@ void WebInterface::HandleCameraList(evhttp_request *req, void *arg) { void WebInterface::HandleCameraDetail(evhttp_request *req, void *arg) { auto *this_ = reinterpret_cast(arg); - int64_t camera_id; + Uuid camera_uuid; QueryParameters params(evhttp_request_get_uri(req)); - if (!params.ok() || !Atoi64(params.Get("id"), 10, &camera_id)) { + if (!params.ok() || !camera_uuid.ParseText(params.Get("uuid"))) { 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()); + if (!this_->mdb_->GetCamera(camera_uuid, &camera_row)) { + return evhttp_send_error(req, HTTP_NOTFOUND, "no such camera"); } EvBuffer buf; @@ -151,11 +148,12 @@ void WebInterface::HandleCameraDetail(evhttp_request *req, void *arg) { aggregated.start_time_90k) / kTimeUnitsPerSecond; buf.AddPrintf( - "%s%s%dx%d" "%.0f%s%s\n", - camera_id, aggregated.start_time_90k, aggregated.end_time_90k, + camera_uuid.UnparseText().c_str(), 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), @@ -169,10 +167,10 @@ void WebInterface::HandleCameraDetail(evhttp_request *req, void *arg) { 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 && + row.end_time_90k == aggregated.start_time_90k && new_duration_90k < kForceSplitDuration90k) { // Append to current .mp4. - aggregated.end_time_90k = row.end_time_90k; + aggregated.start_time_90k = row.start_time_90k; aggregated.video_samples += row.video_samples; aggregated.sample_file_bytes += row.sample_file_bytes; } else { @@ -182,7 +180,11 @@ void WebInterface::HandleCameraDetail(evhttp_request *req, void *arg) { } return IterationControl::kContinue; }; - if (!this_->mdb_->ListCameraRecordings(camera_id, handle_sql_row, + int64_t start_time_90k = 0; + int64_t end_time_90k = std::numeric_limits::max(); + std::string error_message; + if (!this_->mdb_->ListCameraRecordings(camera_uuid, start_time_90k, + end_time_90k, handle_sql_row, &error_message)) { return evhttp_send_error( req, HTTP_INTERNAL, @@ -198,11 +200,11 @@ void WebInterface::HandleCameraDetail(evhttp_request *req, void *arg) { void WebInterface::HandleMp4View(evhttp_request *req, void *arg) { auto *this_ = reinterpret_cast(arg); - int64_t camera_id; + Uuid camera_uuid; 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) || + if (!params.ok() || !camera_uuid.ParseText(params.Get("camera_uuid")) || !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) { @@ -210,7 +212,7 @@ void WebInterface::HandleMp4View(evhttp_request *req, void *arg) { } std::string error_message; - auto file = this_->mdb_->BuildMp4(camera_id, start_time_90k, end_time_90k, + auto file = this_->mdb_->BuildMp4(camera_uuid, start_time_90k, end_time_90k, &error_message); if (file == nullptr) { // TODO: more nuanced HTTP status codes.