// This file is part of Moonfire NVR, a security camera digital 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-nvr-test.cc: tests of the moonfire-nvr.cc interface. #include #include #include #include "moonfire-nvr.h" #include "string.h" #include "testutil.h" DECLARE_bool(alsologtostderr); using testing::_; using testing::AnyNumber; using testing::HasSubstr; using testing::Invoke; using testing::Return; namespace moonfire_nvr { namespace { class MockVideoSource : public VideoSource { public: // Proxy, as gmock doesn't support non-copyable return values. std::unique_ptr OpenRtsp( const std::string &url, std::string *error_message) final { return std::unique_ptr( OpenRtspRaw(url, error_message)); } std::unique_ptr OpenFile( const std::string &file, std::string *error_message) final { return std::unique_ptr( OpenFileRaw(file, error_message)); } MOCK_METHOD2(OpenRtspRaw, InputVideoPacketStream *(const std::string &, std::string *)); MOCK_METHOD2(OpenFileRaw, InputVideoPacketStream *(const std::string &, std::string *)); }; class FileManagerTest : public testing::Test { protected: FileManagerTest() { test_dir_ = PrepareTempDirOrDie("moonfire-nvr-file-manager"); } std::vector GetFilenames(const FileManager &mgr) { std::vector out; mgr.ForEachFile([&out](const std::string &f, const struct stat &) { out.push_back(f); }); return out; } std::string test_dir_; }; TEST_F(FileManagerTest, InitWithNoDirectory) { std::string subdir = test_dir_ + "/" + "subdir"; FileManager manager("foo", subdir, 0); // Should succeed. std::string error_message; EXPECT_TRUE(manager.Init(&error_message)) << error_message; // Should create the directory. struct stat buf; ASSERT_EQ(0, lstat(subdir.c_str(), &buf)) << strerror(errno); EXPECT_TRUE(S_ISDIR(buf.st_mode)); // Should report empty. EXPECT_EQ(0, manager.total_bytes()); EXPECT_THAT(GetFilenames(manager), testing::ElementsAre()); // Adding files: nonexistent, simple, out of order. EXPECT_FALSE(manager.AddFile("nonexistent.mp4", &error_message)); WriteFileOrDie(subdir + "/1.mp4", "1"); WriteFileOrDie(subdir + "/2.mp4", "123"); EXPECT_TRUE(manager.AddFile("2.mp4", &error_message)) << error_message; EXPECT_EQ(3, manager.total_bytes()); EXPECT_THAT(GetFilenames(manager), testing::ElementsAre("2.mp4")); EXPECT_TRUE(manager.AddFile("1.mp4", &error_message)) << error_message; EXPECT_EQ(4, manager.total_bytes()); EXPECT_THAT(GetFilenames(manager), testing::ElementsAre("1.mp4", "2.mp4")); EXPECT_TRUE(manager.Rotate(&error_message)) << error_message; EXPECT_EQ(0, manager.total_bytes()); EXPECT_THAT(GetFilenames(manager), testing::ElementsAre()); } TEST_F(FileManagerTest, InitAndRotateWithExistingFiles) { WriteFileOrDie(test_dir_ + "/1.mp4", "1"); WriteFileOrDie(test_dir_ + "/2.mp4", "123"); WriteFileOrDie(test_dir_ + "/3.mp4", "12345"); WriteFileOrDie(test_dir_ + "/other", "1234567"); FileManager manager("foo", test_dir_, 8); // Should succeed. std::string error_message; EXPECT_TRUE(manager.Init(&error_message)) << error_message; EXPECT_THAT(GetFilenames(manager), testing::ElementsAre("1.mp4", "2.mp4", "3.mp4")); EXPECT_EQ(1 + 3 + 5, manager.total_bytes()); EXPECT_TRUE(manager.Rotate(&error_message)) << error_message; EXPECT_THAT(GetFilenames(manager), testing::ElementsAre("2.mp4", "3.mp4")); EXPECT_EQ(8, manager.total_bytes()); } class StreamTest : public testing::Test { public: StreamTest() { test_dir_ = PrepareTempDirOrDie("moonfire-nvr-stream-copier"); env_.clock = &clock_; env_.video_source = &video_source_; clock_.Sleep({1430006400, 0}); // 2016-04-26 00:00:00 UTC config_.set_base_path(test_dir_); config_.set_rotate_sec(5); auto *camera = config_.add_camera(); camera->set_short_name("test"); camera->set_host("test-camera"); camera->set_user("foo"); camera->set_password("bar"); camera->set_main_rtsp_path("/main"); camera->set_sub_rtsp_path("/sub"); camera->set_retain_bytes(1000000); } // A function to use in OpenRtspRaw invocations which shuts down the stream // and indicates that the input video source can't be opened. InputVideoPacketStream *Shutdown(const std::string &url, std::string *error_message) { *error_message = "(shutting down)"; signal_.Shutdown(); return nullptr; } struct Frame { Frame(bool is_key, int64_t pts, int64_t duration) : is_key(is_key), pts(pts), duration(duration) {} bool is_key; int64_t pts; int64_t duration; bool operator==(const Frame &o) const { return is_key == o.is_key && pts == o.pts && duration == o.duration; } friend std::ostream &operator<<(std::ostream &os, const Frame &f) { return os << "Frame(" << f.is_key << ", " << f.pts << ", " << f.duration << ")"; } }; std::vector GetFrames(const std::string &path) { std::vector frames; std::string error_message; std::string full_path = StrCat(test_dir_, "/test/", path); auto f = GetRealVideoSource()->OpenFile(full_path, &error_message); if (f == nullptr) { ADD_FAILURE() << full_path << ": " << error_message; return frames; } VideoPacket pkt; while (f->GetNext(&pkt, &error_message)) { frames.push_back(Frame(pkt.is_key(), pkt.pts(), pkt.pkt()->duration)); } EXPECT_EQ("", error_message); return frames; } ShutdownSignal signal_; Config config_; SimulatedClock clock_; testing::StrictMock video_source_; Environment env_; std::string test_dir_; }; class ProxyingInputVideoPacketStream : public InputVideoPacketStream { public: explicit ProxyingInputVideoPacketStream( std::unique_ptr base, SimulatedClock *clock) : base_(std::move(base)), clock_(clock) {} bool GetNext(VideoPacket *pkt, std::string *error_message) final { if (pkts_left_-- == 0) { *error_message = "(pkt limit reached)"; return false; } // Advance time to when this packet starts. clock_->Sleep(SecToTimespec(last_duration_sec_)); if (!base_->GetNext(pkt, error_message)) { return false; } last_duration_sec_ = pkt->pkt()->duration * av_q2d(base_->stream()->time_base); // Adjust timestamps. if (ts_offset_pkts_left_ > 0) { pkt->pkt()->pts += ts_offset_; pkt->pkt()->dts += ts_offset_; --ts_offset_pkts_left_; } // Use a fixed duration, as the duration from a real RTSP stream is only // an estimate. Our test video is 1 fps, 90 kHz time base. pkt->pkt()->duration = 90000; return true; } const AVStream *stream() const final { return base_->stream(); } void set_ts_offset(int64_t offset, int pkts) { ts_offset_ = offset; ts_offset_pkts_left_ = pkts; } void set_pkts(int num) { pkts_left_ = num; } private: std::unique_ptr base_; SimulatedClock *clock_ = nullptr; double last_duration_sec_ = 0.; int64_t ts_offset_ = 0; int ts_offset_pkts_left_ = 0; int pkts_left_ = std::numeric_limits::max(); }; TEST_F(StreamTest, Basic) { Stream stream(&signal_, config_, &env_, config_.camera(0)); std::string error_message; ASSERT_TRUE(stream.Init(&error_message)) << error_message; // This is a ~1 fps test video with a timebase of 90 kHz. auto in_stream = GetRealVideoSource()->OpenFile("../src/testdata/clip.mp4", &error_message); ASSERT_TRUE(in_stream != nullptr) << error_message; auto *proxy_stream = new ProxyingInputVideoPacketStream(std::move(in_stream), &clock_); // The starting pts of the input should be irrelevant. proxy_stream->set_ts_offset(180000, std::numeric_limits::max()); EXPECT_CALL(video_source_, OpenRtspRaw("rtsp://foo:bar@test-camera/main", _)) .WillOnce(Return(proxy_stream)) .WillOnce(Invoke(this, &StreamTest::Shutdown)); stream.Run(); // Compare frame-by-frame. // Note below that while the rotation is scheduled to happen near 5-second // boundaries (such as 2016-04-26 00:00:05), it gets deferred until the next // key frame, which in this case is 00:00:07. EXPECT_THAT(stream.GetFilesForTesting(), testing::ElementsAre("20150426000000_test.mp4", "20150426000007_test.mp4")); EXPECT_THAT(GetFrames("20150426000000_test.mp4"), testing::ElementsAre( Frame(true, 0, 90379), Frame(false, 90379, 89884), Frame(false, 180263, 89749), Frame(false, 270012, 89981), Frame(true, 359993, 90055), Frame(false, 450048, 89967), // pts_time 5.000533, past rotation time. Frame(false, 540015, 90021), Frame(false, 630036, 90000))); // XXX: duration=89958 would be better! EXPECT_THAT( GetFrames("20150426000007_test.mp4"), testing::ElementsAre(Frame(true, 0, 90011), Frame(false, 90011, 90000))); // Note that the final "90000" duration is ffmpeg's estimate based on frame // rate. For non-final packets, the correct duration gets written based on // the next packet's timestamp. The same currently applies to the first // written segment---it uses an estimated time, not the real time until the // next packet. This probably should be fixed... } TEST_F(StreamTest, NonIncreasingTimestamp) { Stream stream(&signal_, config_, &env_, config_.camera(0)); std::string error_message; ASSERT_TRUE(stream.Init(&error_message)) << error_message; auto in_stream = GetRealVideoSource()->OpenFile("../src/testdata/clip.mp4", &error_message); ASSERT_TRUE(in_stream != nullptr) << error_message; auto *proxy_stream = new ProxyingInputVideoPacketStream(std::move(in_stream), &clock_); proxy_stream->set_ts_offset(12345678, 1); EXPECT_CALL(video_source_, OpenRtspRaw("rtsp://foo:bar@test-camera/main", _)) .WillOnce(Return(proxy_stream)) .WillOnce(Invoke(this, &StreamTest::Shutdown)); { ScopedMockLog log; EXPECT_CALL(log, Log(_, _, _)).Times(AnyNumber()); EXPECT_CALL(log, Log(_, _, HasSubstr("Rejecting non-increasing pts=90379"))); log.Start(); stream.Run(); } // The output file should still be added to the file manager, with the one // packet that made it. EXPECT_THAT(stream.GetFilesForTesting(), testing::ElementsAre("20150426000000_test.mp4")); EXPECT_THAT( GetFrames("20150426000000_test.mp4"), testing::ElementsAre(Frame(true, 0, 90000))); // estimated duration. } TEST_F(StreamTest, RetryOnInputError) { Stream stream(&signal_, config_, &env_, config_.camera(0)); std::string error_message; ASSERT_TRUE(stream.Init(&error_message)) << error_message; auto in_stream_1 = GetRealVideoSource()->OpenFile("../src/testdata/clip.mp4", &error_message); ASSERT_TRUE(in_stream_1 != nullptr) << error_message; auto *proxy_stream_1 = new ProxyingInputVideoPacketStream(std::move(in_stream_1), &clock_); proxy_stream_1->set_pkts(1); auto in_stream_2 = GetRealVideoSource()->OpenFile("../src/testdata/clip.mp4", &error_message); ASSERT_TRUE(in_stream_2 != nullptr) << error_message; auto *proxy_stream_2 = new ProxyingInputVideoPacketStream(std::move(in_stream_2), &clock_); proxy_stream_2->set_pkts(1); EXPECT_CALL(video_source_, OpenRtspRaw("rtsp://foo:bar@test-camera/main", _)) .WillOnce(Return(proxy_stream_1)) .WillOnce(Return(proxy_stream_2)) .WillOnce(Invoke(this, &StreamTest::Shutdown)); stream.Run(); // Each attempt should have resulted in a file with one packet. EXPECT_THAT(stream.GetFilesForTesting(), testing::ElementsAre("20150426000000_test.mp4", "20150426000001_test.mp4")); EXPECT_THAT(GetFrames("20150426000000_test.mp4"), testing::ElementsAre(Frame(true, 0, 90000))); EXPECT_THAT(GetFrames("20150426000001_test.mp4"), testing::ElementsAre(Frame(true, 0, 90000))); } TEST_F(StreamTest, DiscardInitialNonKeyFrames) { Stream stream(&signal_, config_, &env_, config_.camera(0)); std::string error_message; ASSERT_TRUE(stream.Init(&error_message)) << error_message; auto in_stream = GetRealVideoSource()->OpenFile("../src/testdata/clip.mp4", &error_message); ASSERT_TRUE(in_stream != nullptr) << error_message; // Discard the initial key frame packet. VideoPacket dummy; ASSERT_TRUE(in_stream->GetNext(&dummy, &error_message)) << error_message; auto *proxy_stream = new ProxyingInputVideoPacketStream(std::move(in_stream), &clock_); EXPECT_CALL(video_source_, OpenRtspRaw("rtsp://foo:bar@test-camera/main", _)) .WillOnce(Return(proxy_stream)) .WillOnce(Invoke(this, &StreamTest::Shutdown)); stream.Run(); // Skipped: initial key frame packet (duration 90379) // Ignored: duration 89884, 89749, 89981 (total pts time: 2.99571... sec) // Thus, the first output file should start at 00:00:02. EXPECT_THAT(stream.GetFilesForTesting(), testing::ElementsAre("20150426000002_test.mp4", "20150426000006_test.mp4")); EXPECT_THAT( GetFrames("20150426000002_test.mp4"), testing::ElementsAre( Frame(true, 0, 90055), Frame(false, 90055, 89967), // pts_time 5.000533, past rotation time. Frame(false, 180022, 90021), Frame(false, 270043, 90000))); // XXX: duration=89958 would be better! EXPECT_THAT( GetFrames("20150426000006_test.mp4"), testing::ElementsAre(Frame(true, 0, 90011), Frame(false, 90011, 90000))); } // TODO: test output stream error (on open, writing packet, closing). } // 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(); }