diff --git a/Cargo.lock b/Cargo.lock index 2a054f5..461bb8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,6 +5,7 @@ dependencies = [ "byteorder 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)", "chan-signal 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cursive 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "docopt 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", "ffmpeg 0.2.0-alpha.2 (git+https://github.com/scottlamb/rust-ffmpeg?branch=2.x)", "ffmpeg-sys 2.8.9 (registry+https://github.com/rust-lang/crates.io-index)", @@ -51,6 +52,14 @@ dependencies = [ "memchr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "bit-set" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bit-vec 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "bit-set" version = "0.4.0" @@ -87,6 +96,17 @@ dependencies = [ "rand 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "chan-signal" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bit-set 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)", + "lazy_static 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "chan-signal" version = "0.2.0" @@ -112,6 +132,21 @@ name = "crossbeam" version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "cursive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)", + "chan-signal 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", + "ncurses 5.85.0 (registry+https://github.com/rust-lang/crates.io-index)", + "num 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)", + "odds 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)", + "toml 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-segmentation 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "docopt" version = "0.7.0" @@ -254,6 +289,11 @@ name = "language-tags" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "lazy_static" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "lazy_static" version = "0.2.2" @@ -342,16 +382,48 @@ dependencies = [ "log 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "ncurses" +version = "5.85.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "gcc 0.3.42 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "num" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ + "num-bigint 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", + "num-complex 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", "num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", "num-iter 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", + "num-rational 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", "num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "num-bigint" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", + "num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)", + "rand 0.3.15 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "num-complex" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "num-integer" version = "0.1.32" @@ -369,6 +441,17 @@ dependencies = [ "num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "num-rational" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num-bigint 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", + "num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", + "num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "num-traits" version = "0.1.36" @@ -390,6 +473,11 @@ dependencies = [ "libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "odds" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "openssl" version = "0.9.6" @@ -726,6 +814,9 @@ dependencies = [ name = "toml" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rustc-serialize 0.3.22 (registry+https://github.com/rust-lang/crates.io-index)", +] [[package]] name = "traitobject" @@ -758,6 +849,16 @@ name = "unicode-normalization" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "unicode-segmentation" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "unicode-width" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "unicode-xid" version = "0.0.4" @@ -826,15 +927,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [metadata] "checksum aho-corasick 0.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ca972c2ea5f742bfce5687b9aef75506a764f61d37f8f649047846a9686ddb66" "checksum aho-corasick 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4f660b942762979b56c9f07b4b36bb559776fbad102f05d6771e1b629e8fd5bf" +"checksum bit-set 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "e6e1e6fb1c9e3d6fcdec57216a74eaa03e41f52a22f13a16438251d8e88b89da" "checksum bit-set 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d9bf6104718e80d7b26a68fdbacff3481cfc05df670821affc7e9cbc1884400c" "checksum bit-vec 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "5b97c2c8e8bbb4251754f559df8af22fb264853c7d009084a576cdf12565089d" "checksum bitflags 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "32866f4d103c4e438b1db1158aa1b1a80ee078e5d77a59a2f906fd62a577389c" "checksum bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d" "checksum byteorder 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c40977b0ee6b9885c9013cd41d9feffdd22deb3bb4dc3a71d901cc7a77de18c8" "checksum chan 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)" = "82b22acfef7960fd8f829bc50749273be637cbd76b9d4cc20497666cc3a33329" +"checksum chan-signal 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "365122ab60a9dc6240b48e39d011b4389c3853093d98bf586edd2b79bfb4fbfa" "checksum chan-signal 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "0f3bb6c3bc387004ad914f0c5b7f33ace8bf7604bbec35f228b1a017f52cd3a0" "checksum chrono 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)" = "9213f7cd7c27e95c2b57c49f0e69b1ea65b27138da84a170133fd21b07659c00" "checksum crossbeam 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)" = "0c5ea215664ca264da8a9d9c3be80d2eaf30923c259d03e870388eb927508f97" +"checksum cursive 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8d226ba768f44025aeec3485ce3ca9c7e93c3f2a7e02e8f00d5b4c70c490011b" "checksum docopt 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ab32ea6e284d87987066f21a9e809a73c14720571ef34516f0890b3d355ccfd8" "checksum dtoa 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "0dd841b58510c9618291ffa448da2e4e0f699d984d436122372f446dae62263d" "checksum error-chain 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "318cb3c71ee4cdea69fdc9e15c173b245ed6063e1709029e8fd32525a881120f" @@ -852,6 +956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum itoa 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ae3088ea4baeceb0284ee9eea42f591226e6beaecf65373e41b38d95a1b8e7a1" "checksum kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" "checksum language-tags 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a" +"checksum lazy_static 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "cf186d1a8aa5f5bee5fd662bc9c1b949e0259e1bcc379d1f006847b0080c7417" "checksum lazy_static 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6abe0ee2e758cd6bc8a2cd56726359007748fbf4128da998b65d0b70f881e19b" "checksum libc 0.2.20 (registry+https://github.com/rust-lang/crates.io-index)" = "684f330624d8c3784fb9558ca46c4ce488073a8d22450415c5eb4f4cfb0d11b5" "checksum libsqlite3-sys 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b6de3eea39ba6ed0cddf04e1c7a78486e3f750441e0a0b15b6ea39d0dd8e1b8c" @@ -864,12 +969,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum memmap 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "065ce59af31c18ea2c419100bda6247dd4ec3099423202b12f0bd32e529fabd2" "checksum metadeps 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "829fffe7ea1d747e23f64be972991bc516b2f1ac2ae4a3b33d8bea150c410151" "checksum mime 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b5c93a4bd787ddc6e7833c519b73a50883deb5863d76d9b71eb8216fb7f94e66" +"checksum ncurses 5.85.0 (registry+https://github.com/rust-lang/crates.io-index)" = "21f71f0e1ae96558612b1e9d188ec4f23149a11ee4fb4b96e130523bea52d605" "checksum num 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)" = "bde7c03b09e7c6a301ee81f6ddf66d7a28ec305699e3d3b056d2fc56470e3120" +"checksum num-bigint 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "88b14378471f7c2adc5262f05b4701ef53e8da376453a8d8fee48e51db745e49" +"checksum num-complex 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "f0c78e054dd19c3fd03419ade63fa661e9c49bb890ce3beb4eee5b7baf93f92f" "checksum num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "fb24d9bfb3f222010df27995441ded1e954f8f69cd35021f6bef02ca9552fb92" "checksum num-iter 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "287a1c9969a847055e1122ec0ea7a5c5d6f72aad97934e131c83d5c08ab4e45c" +"checksum num-rational 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)" = "54ff603b8334a72fbb27fe66948aac0abaaa40231b3cecd189e76162f6f38aaf" "checksum num-traits 0.1.36 (registry+https://github.com/rust-lang/crates.io-index)" = "a16a42856a256b39c6d3484f097f6713e14feacd9bfb02290917904fae46c81c" "checksum num_cpus 0.2.13 (registry+https://github.com/rust-lang/crates.io-index)" = "cee7e88156f3f9e19bdd598f8d6c9db7bf4078f99f8381f43a55b09648d1a6e3" "checksum num_cpus 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a225d1e2717567599c24f88e49f00856c6e825a12125181ee42c4257e3688d39" +"checksum odds 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)" = "c3df9b730298cea3a1c3faa90b7e2f9df3a9c400d0936d6015e6165734eefcba" "checksum openssl 0.9.6 (registry+https://github.com/rust-lang/crates.io-index)" = "0c00da69323449142e00a5410f0e022b39e8bbb7dc569cee8fc6af279279483c" "checksum openssl-sys 0.9.6 (registry+https://github.com/rust-lang/crates.io-index)" = "b1482f9a06f56c906007e17ea14d73d102210b5d27bc948bf5e175f493f3f7c3" "checksum pkg-config 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "3a8b4c6b8165cd1a1cd4b9b120978131389f64bdaf456435caa41e630edba903" @@ -914,6 +1024,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum unicase 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "13a5906ca2b98c799f4b1ab4557b76367ebd6ae5ef14930ec841c74aed5f3764" "checksum unicode-bidi 0.2.4 (registry+https://github.com/rust-lang/crates.io-index)" = "b61814f3e7fd0e0f15370f767c7c943e08bc2e3214233ae8f88522b334ceb778" "checksum unicode-normalization 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "5e94e9f6961090fcc75180629c4ef33e5310d6ed2c0dd173f4ca63c9043b669e" +"checksum unicode-segmentation 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7baebdc1df1363fa66161fca2fe047e4f4209011cc7e045948298996afdf85df" +"checksum unicode-width 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "bf3a113775714a22dcb774d8ea3655c53a32debae63a063acc00a91cc586245f" "checksum unicode-xid 0.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8c1f860d7d29cf02cb2f3f359fd35991af3d30bac52c57d265a3c461074cb4dc" "checksum unreachable 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1f2ae5ddb18e1c92664717616dd9549dde73f539f01bd7b77c2edb2446bdff91" "checksum url 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f5ba8a749fb4479b043733416c244fa9d1d3af3d7c23804944651c8a448cb87e" diff --git a/Cargo.toml b/Cargo.toml index 4a7e599..3972e43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,11 @@ serde_codegen = "0.8" [dev-dependencies] tempdir = "0.3" +[dependencies.cursive] +version = "0.4" +#default-features = false +#features = ["termion"] + [dependencies.ffmpeg] git = "https://github.com/scottlamb/rust-ffmpeg" branch = "2.x" diff --git a/README.md b/README.md index 3468b63..b3ad3af 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,9 @@ You will need the following C libraries installed: * [SQLite3](https://www.sqlite.org/). +* [`ncursesw`](https://www.gnu.org/software/ncurses/), the UTF-8 version of + the `ncurses` library. + On Ubuntu 16.04.1 LTS or Raspbian Jessie, the following command will install all non-Rust dependencies: @@ -97,13 +100,8 @@ all non-Rust dependencies: libavcodec-dev \ libavformat-dev \ libavutil-dev \ - sqlite3 \ - libsqlite3-dev \ - uuid-runtime - -uuid-runtime is only necessary if you wish to use the uuid command to generate -uuids for your cameras (see below). If you obtain them elsewhere, you can skip this -package. + libncursesw-dev \ + libsqlite3-dev Next, you need Rust and Cargo. The easiest way to install them is by following the instructions at [rustup.rs](https://www.rustup.rs/). Note that Rust 1.13 @@ -119,7 +117,6 @@ build and install, or alternatively you can run the prep script called `prep.sh` The script will take the following command line options, should you need them: -* `-D`: Skip database initialization. * `-S`: Skip updating and installing dependencies through apt-get. This too can be useful on repeated builds. @@ -133,7 +130,7 @@ store the video samples inside a directory named `samples` there, you would set: SAMPLES_DIR=/media/nvr/samples The script will perform all necessary steps to leave you with a fully built, -installed moonfire-nvr binary and (running) system service. The only thing +installed moonfire-nvr binary. The only thing you'll have to do manually is add your camera configuration(s) to the database. Alternatively, before running the script, you can create a file named `cameras.sql` in the same directory as the `prep.sh` script and it will be automatically @@ -180,82 +177,35 @@ If a dedicated hard drive is available, set up the mount point: $ sudo mount /var/lib/moonfire-nvr/sample Once setup is complete, it is time to add camera configurations to the -database. However, the interface for adding new cameras is not yet written, -so you will have to manually insert cameras configurations with the `sqlite3` -command line tool prior to starting Moonfire NVR. +database. If the daemon is running, you will need to stop it temporarily: -Before setting up a camera, it may be helpful to test settings with the -`ffmpeg` command line tool: + $ sudo systemctl stop moonfire-nvr - $ ffmpeg \ - -i "rtsp://admin:12345@192.168.1.101:554/Streaming/Channels/1" \ - -c copy \ - -map 0:0 \ - -rtsp_transport tcp \ - -flags:v +global_header \ - test.mp4 +You can configure the system through a text-based user interface: -Once you have a working `ffmpeg` command line, insert the camera config as -follows. See the schema SQL file's comments for more information. -Note that the sum of `retain_bytes` for all cameras combined should be -somewhat less than the available bytes on the sample file directory's -filesystem, as the currently-writing sample files are not included in -this sum. Be sure also to subtract out the filesystem's reserve for root -(typically 5%). + $ sudo -u moonfire-nvr moonfire-nvr config -In the following example, we generate a uuid which is then later used -to uniquely identify this camera. Thus, you will generate a new one for -each camera you insert using this method. +In the user interface, add your cameras under the "Edit cameras" dialog. +There's a "Test" button to verify your settings directly from the dialog. - $ uuidgen | sed -e 's/-//g' - b47f48706d91414591cd6c931bf836b4 - $ sudo -u moonfire-nvr sqlite3 ~moonfire-nvr/db/db - sqlite3> insert into camera ( - ...> uuid, short_name, description, host, username, password, - ...> main_rtsp_path, sub_rtsp_path, retain_bytes, - ...> next_recording_id) values ( - ...> X'b47f48706d91414591cd6c931bf836b4', 'driveway', - ...> 'Longer description of this camera', '192.168.1.101', - ...> 'admin', '12345', '/Streaming/Channels/1', - ...> '/Streaming/Channels/2', 104857600, 0); - sqlite3> ^D +After the cameras look correct, go to "Edit retention" to assign disk space to +each camera. Leave a little slack (at least 100 MB per camera) between the total +limit and the filesystem capacity, even if you store nothing else on the disk. +There are several reasons this is needed: -### Using automatic camera configuration inclusion with `prep.sh` + * The limit currently controls fully-written files only. There will be up + to two minutes of video per camera of additional video. + * The rotation happens after the limit is exceeded, not proactively. + * Moonfire NVR currently doesn't account for the unused space in the final + filesystem block at the end of each file. + * Moonfire NVR doesn't account for the space used for directory listings. + * If a file is open when it is deleted (such as if a HTTP client is + downloading it), it stays around until the file is closed. Moonfire NVR + currently doesn't account for this. -Not withstanding the instructions above, you can also prepare a file named -`cameras.sql` before you run the `prep.sh` script. The format of this file -should be something like in the example below for two cameras (SQL gives you -lots of freedom in the use of blank space and newlines, so this is formatted -for easy reading, and editing, and does not have to be altered in formatting, -but can if you wish and know what you are doing): +When finished, start the daemon: - insert into camera ( - uuid, - short_name, description, - host, username, password, - main_rtsp_path, sub_rtsp_path, - retain_bytes, next_recording_id - ) - values - ( - X'1c944181b8074b8083eb579c8e194451', - 'Front Left', 'Front Left Driveway', - '192.168.1.41', - 'admin', 'secret', - '/Streaming/Channels/1', '/Streaming/Channels/2', - 346870912000, 0 - ), - ( - X'da5921f493ac4279aafe68e69e174026', - 'Front Right', 'Front Right Driveway', - '192.168.1.42', - 'admin', 'secret', - '/Streaming/Channels/1', '/Streaming/Channels/2', - 346870912000, 0 - ); - -You'll still have to find the correct rtsp paths, usernames and passwords, and -set retained byte counts, as explained above. + $ sudo systemctl start moonfire-nvr ## System Service @@ -290,9 +240,9 @@ directly exposed to the Internet. Complete the installation through `systemctl` commands: $ sudo systemctl daemon-reload - $ sudo systemctl start moonfire-nvr.service - $ sudo systemctl status moonfire-nvr.service - $ sudo systemctl enable moonfire-nvr.service + $ sudo systemctl start moonfire-nvr + $ sudo systemctl status moonfire-nvr + $ sudo systemctl enable moonfire-nvr See the [systemd](http://www.freedesktop.org/wiki/Software/systemd/) documentation for more information. The [manual diff --git a/prep.sh b/prep.sh index 52e4a5f..14b57df 100755 --- a/prep.sh +++ b/prep.sh @@ -34,7 +34,6 @@ # Script to prepare for moonfire-nvr operations # # Command line options: -# -D: Skip database initialization # -S: Skip apt-get update and install # @@ -106,7 +105,6 @@ NVR_GROUP="${NVR_GROUP:-$NVR_USER}" NVR_PORT="${NVR_PORT:-8080}" NVR_HOME_BASE="${NVR_HOME_BASE:-/var/lib}" NVR_HOME="${NVR_HOME_BASE}/${NVR_USER}" -DB_NAME="${DB_NAME:-db}" DB_DIR="${DB_DIR:-$NVR_HOME/db}" SAMPLES_DIR_NAME="${SAMPLES_DIR_NAME:-samples}" SAMPLES_DIR="${SAMPLES_DIR:-$NVR_HOME/$SAMPLES_DIR_NAME}" @@ -214,13 +212,7 @@ echo 'Create database...'; echo if [ ! -d "${DB_DIR}" ]; then sudo -u ${NVR_USER} -H mkdir "${DB_DIR}" fi -DB_PATH="${DB_DIR}/${DB_NAME}" -CAMERAS_PATH="${SRC_DIR}/../cameras.sql" -[ "${SKIP_DB:-0}" == 0 ] && sudo -u ${NVR_USER} -H ${SERVICE_BIN} init --db-dir="${DB_PATH}" -if [ -r "${CAMERAS_PATH}" ]; then - echo 'Add cameras...'; echo - sudo -u ${NVR_USER} -H sqlite3 "${DB_PATH}" < "${CAMERAS_PATH}" -fi +sudo -u ${NVR_USER} -H ${SERVICE_BIN} init --db-dir="${DB_DIR}" # Prepare service files # diff --git a/src/cmds/config/cameras.rs b/src/cmds/config/cameras.rs new file mode 100644 index 0000000..f3e4671 --- /dev/null +++ b/src/cmds/config/cameras.rs @@ -0,0 +1,270 @@ +// This file is part of Moonfire NVR, a security camera digital video recorder. +// Copyright (C) 2017 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 . + +extern crate cursive; + +use self::cursive::Cursive; +use self::cursive::traits::{Boxable, Identifiable, Finder}; +use self::cursive::views; +use db; +use dir; +use error::Error; +use std::sync::Arc; +use stream::{self, Opener, Stream}; +use super::{decode_size, encode_size}; + +/// Builds a `CameraChange` from an active `edit_camera_dialog`. +fn get_change(siv: &mut Cursive) -> db::CameraChange { + db::CameraChange{ + short_name: siv.find_id::("short_name") + .unwrap().get_content().as_str().into(), + description: siv.find_id::("description").unwrap().get_content().into(), + host: siv.find_id::("host").unwrap().get_content().as_str().into(), + username: siv.find_id::("username").unwrap().get_content().as_str().into(), + password: siv.find_id::("password").unwrap().get_content().as_str().into(), + main_rtsp_path: siv.find_id::("main_rtsp_path") + .unwrap().get_content().as_str().into(), + sub_rtsp_path: siv.find_id::("sub_rtsp_path") + .unwrap().get_content().as_str().into(), + } +} + +fn press_edit(siv: &mut Cursive, db: &Arc, dir: &Arc, + id: Option) { + let change = get_change(siv); + siv.pop_layer(); // get rid of the add/edit camera dialog. + + let result = { + let mut l = db.lock(); + if let Some(id) = id { + l.update_camera(id, change) + } else { + l.add_camera(change).map(|_| ()) + } + }; + if let Err(e) = result { + siv.add_layer(views::Dialog::text(format!("Unable to add camera: {}", e)) + .title("Error") + .dismiss_button("Abort")); + } else { + // Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry. + siv.pop_layer(); + add_dialog(db, dir, siv); + } +} + +fn press_test_inner(url: &str) -> Result { + let stream = stream::FFMPEG.open(stream::Source::Rtsp(url))?; + let extra_data = stream.get_extra_data()?; + Ok(format!("{}x{} video stream", extra_data.width, extra_data.height)) +} + +fn press_test(siv: &mut Cursive, c: &db::CameraChange, stream: &str, path: &str) { + let url = format!("rtsp://{}:{}@{}{}", c.username, c.password, c.host, path); + let description = match press_test_inner(&url) { + Err(e) => { + siv.add_layer( + views::Dialog::text(format!("{} stream at {}:\n\n{}", stream, url, e)) + .title("Stream test failed") + .dismiss_button("Back")); + return; + }, + Ok(d) => d, + }; + siv.add_layer(views::Dialog::text(format!("{} stream at {}:\n\n{}", stream, url, description)) + .title("Stream test succeeded") + .dismiss_button("Back")); +} + +fn press_delete(siv: &mut Cursive, db: &Arc, dir: &Arc, id: i32, + name: String, to_delete: i64) { + let dialog = if to_delete > 0 { + let prompt = format!("Camera {} has recorded video. Please confirm the amount \ + of data to delete by typing it back:\n\n{}", name, + encode_size(to_delete)); + views::Dialog::around( + views::LinearLayout::vertical() + .child(views::TextView::new(prompt)) + .child(views::DummyView) + .child(views::EditView::new().on_submit({ + let db = db.clone(); + let dir = dir.clone(); + move |siv, _| confirm_deletion(siv, &db, &dir, id, to_delete) + }).with_id("confirm"))) + .button("Delete", { + let db = db.clone(); + let dir = dir.clone(); + move |siv| confirm_deletion(siv, &db, &dir, id, to_delete) + }) + } else { + views::Dialog::text(format!("Delete camera {}? This camera has no recorded video.", name)) + .button("Delete", { + let db = db.clone(); + let dir = dir.clone(); + move |s| actually_delete(s, &db, &dir, id) + }) + }.title("Delete camera").dismiss_button("Cancel"); + siv.add_layer(dialog); +} + +fn confirm_deletion(siv: &mut Cursive, db: &Arc, dir: &Arc, + id: i32, to_delete: i64) { + let typed = siv.find_id::("confirm").unwrap().get_content(); + if decode_size(typed.as_str()).ok() == Some(to_delete) { + siv.pop_layer(); // deletion confirmation dialog + if let Err(e) = dir::lower_retention(dir.clone(), + &[dir::NewLimit{camera_id: id, limit: 0}]) { + siv.add_layer(views::Dialog::text(format!("Unable to delete recordings: {}", e)) + .title("Error") + .dismiss_button("Abort")); + return; + } + actually_delete(siv, db, dir, id); + } else { + siv.add_layer(views::Dialog::text("Please confirm amount.") + .title("Try again") + .dismiss_button("Back")); + } +} + +fn actually_delete(siv: &mut Cursive, db: &Arc, dir: &Arc, + id: i32) { + info!("actually_delete call"); + siv.pop_layer(); // get rid of the add/edit camera dialog. + let result = { + let mut l = db.lock(); + l.delete_camera(id) + }; + if let Err(e) = result { + siv.add_layer(views::Dialog::text(format!("Unable to delete camera: {}", e)) + .title("Error") + .dismiss_button("Abort")); + } else { + // Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry. + siv.pop_layer(); + add_dialog(db, dir, siv); + } +} + +/// Adds or updates a camera. +/// (The former if `item` is None; the latter otherwise.) +fn edit_camera_dialog(db: &Arc, dir: &Arc, siv: &mut Cursive, + item: &Option) { + let list = views::ListView::new() + .child("id", views::TextView::new(match *item { + None => "".to_string(), + Some(id) => id.to_string(), + })) + .child("uuid", views::TextView::new("").with_id("uuid")) + .child("short name", views::EditView::new().with_id("short_name")) + .child("host", views::EditView::new().with_id("host")) + .child("username", views::EditView::new().with_id("username")) + .child("password", views::EditView::new().with_id("password")) + .child("main_rtsp_path", views::LinearLayout::horizontal() + .child(views::EditView::new().with_id("main_rtsp_path").full_width()) + .child(views::DummyView) + .child(views::Button::new("Test", |siv| { + let c = get_change(siv); + press_test(siv, &c, "main", &c.main_rtsp_path) + }))) + .child("sub_rtsp_path", views::LinearLayout::horizontal() + .child(views::EditView::new().with_id("sub_rtsp_path").full_width()) + .child(views::DummyView) + .child(views::Button::new("Test", |siv| { + let c = get_change(siv); + press_test(siv, &c, "sub", &c.sub_rtsp_path) + }))) + .min_height(8); + let layout = views::LinearLayout::vertical() + .child(list) + .child(views::TextView::new("description")) + .child(views::TextArea::new().with_id("description").min_height(3)) + .full_width(); + let mut dialog = views::Dialog::around(layout); + let dialog = if let Some(id) = *item { + let l = db.lock(); + let camera = l.cameras_by_id().get(&id).expect("missing camera"); + dialog.find_id::("uuid") + .expect("missing TextView") + .set_content(camera.uuid.to_string()); + let bytes = camera.sample_file_bytes; + let name = camera.short_name.clone(); + for &(view_id, content) in &[("short_name", &camera.short_name), + ("host", &camera.host), + ("username", &camera.username), + ("password", &camera.password), + ("main_rtsp_path", &camera.main_rtsp_path), + ("sub_rtsp_path", &camera.sub_rtsp_path)] { + dialog.find_id::(view_id) + .expect("missing EditView") + .set_content(content.to_string()); + } + dialog.find_id::("description") + .expect("missing TextArea") + .set_content(camera.description.to_string()); + dialog.title("Edit camera") + .button("Edit", { + let db = db.clone(); + let dir = dir.clone(); + move |s| press_edit(s, &db, &dir, Some(id)) + }) + .button("Delete", { + let db = db.clone(); + let dir = dir.clone(); + move |s| press_delete(s, &db, &dir, id, name.clone(), bytes) + }) + } else { + dialog.title("Add camera") + .button("Add", { + let db = db.clone(); + let dir = dir.clone(); + move |s| press_edit(s, &db, &dir, None) + }) + }; + siv.add_layer(dialog.dismiss_button("Cancel")); +} + +pub fn add_dialog(db: &Arc, dir: &Arc, siv: &mut Cursive) { + siv.add_layer(views::Dialog::around( + views::SelectView::new() + .on_submit({ + let db = db.clone(); + let dir = dir.clone(); + move |siv, item| edit_camera_dialog(&db, &dir, siv, item) + }) + .item("".to_string(), None) + .with_all(db.lock() + .cameras_by_id() + .iter() + .map(|(&id, camera)| (format!("{}: {}", id, camera.short_name), Some(id)))) + .full_width()) + .dismiss_button("Done") + .title("Edit cameras")); +} diff --git a/src/cmds/config/mod.rs b/src/cmds/config/mod.rs new file mode 100644 index 0000000..802895e --- /dev/null +++ b/src/cmds/config/mod.rs @@ -0,0 +1,163 @@ +// This file is part of Moonfire NVR, a security camera digital video recorder. +// Copyright (C) 2017 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 . + +//! Text-based configuration interface. +//! +//! This code is a bit messy, but it's essentially a prototype. Eventually Moonfire NVR's +//! configuration will likely be almost entirely done through a web-based UI. + +extern crate cursive; + +use self::cursive::Cursive; +use self::cursive::views; +use db; +use dir; +use error::Error; +use regex::Regex; +use std::sync::Arc; +use std::fmt::Write; +use std::str::FromStr; + +mod cameras; +mod retention; + +static USAGE: &'static str = r#" +Interactive configuration editor. + +Usage: + + moonfire-nvr config [options] + moonfire-nvr config --help + +Options: + + --db-dir=DIR Set the directory holding the SQLite3 index database. + This is typically on a flash device. + [default: /var/lib/moonfire-nvr/db] + --sample-file-dir=DIR Set the directory holding video data. + This is typically on a hard drive. + [default: /var/lib/moonfire-nvr/sample] +"#; + +static MULTIPLIERS: [(char, u64); 4] = [ + // (suffix character, power of 2) + ('T', 40), + ('G', 30), + ('M', 20), + ('K', 10), +]; + +fn encode_size(mut raw: i64) -> String { + let mut encoded = String::new(); + for &(c, n) in &MULTIPLIERS { + if raw >= 1i64<> n, c).unwrap(); + raw &= (1i64 << n) - 1; + } + } + if raw > 0 || encoded.len() == 0 { + write!(&mut encoded, "{}", raw).unwrap(); + } else { + encoded.pop(); // remove trailing space. + } + encoded +} + +fn decode_size(encoded: &str) -> Result { + let mut decoded = 0i64; + lazy_static! { + static ref RE: Regex = Regex::new(r"\s*([0-9]+)([TGMK])?,?\s*").unwrap(); + } + let mut last_pos = 0; + for cap in RE.captures_iter(encoded) { + let whole_cap = cap.get(0).unwrap(); + if whole_cap.start() > last_pos { + return Err(()); + } + last_pos = whole_cap.end(); + let mut piece = i64::from_str(&cap[1]).map_err(|_| ())?; + if let Some(m) = cap.get(2) { + let m = m.as_str().as_bytes()[0] as char; + for &(some_m, n) in &MULTIPLIERS { + if some_m == m { + piece *= 1i64< Result<(), Error> { + let args: Args = super::parse_args(USAGE)?; + super::install_logger(false); + let (_db_dir, conn) = super::open_conn(&args.flag_db_dir, super::OpenMode::ReadWrite)?; + let db = Arc::new(db::Database::new(conn)?); + //let dir = Arc::new(dir::Fd::open(&args.flag_sample_file_dir)?); + let dir = dir::SampleFileDir::new(&args.flag_sample_file_dir, db.clone())?; + + let mut siv = Cursive::new(); + //siv.add_global_callback('q', |s| s.quit()); + + siv.add_layer(views::Dialog::around( + views::SelectView::, &Arc, &mut Cursive)>::new() + .on_submit({ + let db = db.clone(); + let dir = dir.clone(); + move |siv, item| item(&db, &dir, siv) + }) + .item("Edit cameras".to_string(), cameras::add_dialog) + .item("Edit retention".to_string(), retention::add_dialog)) + .button("Quit", |siv| siv.quit()) + .title("Main menu")); + + siv.run(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_decode() { + assert_eq!(super::decode_size("100M").unwrap(), 100i64 << 20); + } +} diff --git a/src/cmds/config/retention.rs b/src/cmds/config/retention.rs new file mode 100644 index 0000000..e635963 --- /dev/null +++ b/src/cmds/config/retention.rs @@ -0,0 +1,265 @@ +// This file is part of Moonfire NVR, a security camera digital video recorder. +// Copyright (C) 2017 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 . + +extern crate cursive; + +use self::cursive::Cursive; +use self::cursive::traits::{Boxable, Identifiable}; +use self::cursive::views; +use db; +use dir; +use error::Error; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::rc::Rc; +use std::sync::Arc; +use super::{decode_size, encode_size}; + +struct Camera { + label: String, + used: i64, + retain: Option, // None if unparseable +} + +struct Model { + db: Arc, + dir: Arc, + fs_capacity: i64, + total_used: i64, + total_retain: i64, + errors: isize, + cameras: BTreeMap, +} + +/// Updates the limits in the database. Doesn't delete excess data (if any). +fn update_limits_inner(model: &Model) -> Result<(), Error> { + let mut db = model.db.lock(); + let mut tx = db.tx()?; + for (&id, camera) in &model.cameras { + tx.update_retention(id, camera.retain.unwrap())?; + } + tx.commit() +} + +fn update_limits(model: &Model, siv: &mut Cursive) { + if let Err(e) = update_limits_inner(model) { + siv.add_layer(views::Dialog::text(format!("Unable to update limits: {}", e)) + .dismiss_button("Back") + .title("Error")); + } +} + +fn edit_limit(model: &RefCell, siv: &mut Cursive, id: i32, content: &str) { + info!("on_edit called for id {}", id); + let mut model = model.borrow_mut(); + let model: &mut Model = &mut *model; + let camera = model.cameras.get_mut(&id).unwrap(); + let new_value = decode_size(content).ok(); + let delta = new_value.unwrap_or(0) - camera.retain.unwrap_or(0); + let old_errors = model.errors; + if delta != 0 { + let prev_over = model.total_retain > model.fs_capacity; + model.total_retain += delta; + siv.find_id::("total_retain") + .unwrap() + .set_content(encode_size(model.total_retain)); + let now_over = model.total_retain > model.fs_capacity; + info!("now_over: {}", now_over); + if now_over != prev_over { + model.errors += if now_over { 1 } else { -1 }; + siv.find_id::("total_ok") + .unwrap() + .set_content(if now_over { "*" } else { " " }); + } + } + if new_value.is_none() != camera.retain.is_none() { + model.errors += if new_value.is_none() { 1 } else { -1 }; + siv.find_id::(&format!("{}_ok", id)) + .unwrap() + .set_content(if new_value.is_none() { "*" } else { " " }); + } + camera.retain = new_value; + info!("model.errors = {}", model.errors); + if (model.errors == 0) != (old_errors == 0) { + info!("toggling change state: errors={}", model.errors); + siv.find_id::("change") + .unwrap() + .set_enabled(model.errors == 0); + } +} + +fn confirm_deletion(model: &RefCell, siv: &mut Cursive, to_delete: i64) { + let typed = siv.find_id::("confirm") + .unwrap() + .get_content(); + info!("confirm, typed: {} vs expected: {}", typed.as_str(), to_delete); + if decode_size(typed.as_str()).ok() == Some(to_delete) { + actually_delete(model, siv); + } else { + siv.add_layer(views::Dialog::text("Please confirm amount.") + .title("Try again") + .dismiss_button("Back")); + } +} + +fn actually_delete(model: &RefCell, siv: &mut Cursive) { + let model = &*model.borrow(); + let new_limits: Vec<_> = + model.cameras.iter() + .map(|(&id, c)| dir::NewLimit{camera_id: id, limit: c.retain.unwrap()}) + .collect(); + siv.pop_layer(); // deletion confirmation + siv.pop_layer(); // retention dialog + if let Err(e) = dir::lower_retention(model.dir.clone(), &new_limits[..]) { + siv.add_layer(views::Dialog::text(format!("Unable to delete excess video: {}", e)) + .title("Error") + .dismiss_button("Abort")); + } else { + update_limits(model, siv); + } +} + +fn press_change(model: &Rc>, siv: &mut Cursive) { + if model.borrow().errors > 0 { + return; + } + let to_delete = model.borrow().cameras.values().map( + |c| ::std::cmp::max(c.used - c.retain.unwrap(), 0)).sum(); + info!("change press, to_delete={}", to_delete); + if to_delete > 0 { + let prompt = format!("Some cameras' usage exceeds new limit. Please confirm the amount \ + of data to delete by typing it back:\n\n{}", encode_size(to_delete)); + let dialog = views::Dialog::around( + views::LinearLayout::vertical() + .child(views::TextView::new(prompt)) + .child(views::DummyView) + .child(views::EditView::new().on_submit({ + let model = model.clone(); + move |siv, _| confirm_deletion(&model, siv, to_delete) + }).with_id("confirm"))) + .button("Confirm", { + let model = model.clone(); + move |siv| confirm_deletion(&model, siv, to_delete) + }) + .dismiss_button("Cancel") + .title("Confirm deletion"); + siv.add_layer(dialog); + } else { + siv.screen_mut().pop_layer(); + update_limits(&model.borrow(), siv); + } +} + +pub fn add_dialog(db: &Arc, dir: &Arc, siv: &mut Cursive) { + let model = { + let mut cameras = BTreeMap::new(); + let mut total_used = 0; + let mut total_retain = 0; + { + let db = db.lock(); + for (&id, camera) in db.cameras_by_id() { + cameras.insert(id, Camera{ + label: format!("{}: {}", id, camera.short_name), + used: camera.sample_file_bytes, + retain: Some(camera.retain_bytes), + }); + total_used += camera.sample_file_bytes; + total_retain += camera.retain_bytes; + } + } + let stat = dir.statfs().unwrap(); + let fs_capacity = (stat.f_bsize * (stat.f_blocks - stat.f_bfree + stat.f_bavail)) as i64 - + total_used; + Rc::new(RefCell::new(Model{ + dir: dir.clone(), + db: db.clone(), + fs_capacity: fs_capacity, + total_used: total_used, + total_retain: total_retain, + errors: (total_retain > fs_capacity) as isize, + cameras: cameras, + })) + }; + + let mut list = views::ListView::new(); + list.add_child( + "camera", + views::LinearLayout::horizontal() + .child(views::TextView::new("usage").fixed_width(25)) + .child(views::TextView::new("limit").fixed_width(25))); + for (&id, camera) in &model.borrow().cameras { + list.add_child( + &camera.label, + views::LinearLayout::horizontal() + .child(views::TextView::new(encode_size(camera.used)).fixed_width(25)) + .child(views::EditView::new() + .content(encode_size(camera.retain.unwrap())) + .on_edit({ + let model = model.clone(); + move |siv, content, _pos| edit_limit(&model, siv, id, content) + }) + .on_submit({ + let model = model.clone(); + move |siv, _| press_change(&model, siv) + }) + .fixed_width(25)) + .child(views::TextView::new("").with_id(&format!("{}_ok", id)).fixed_width(1))); + } + let over = model.borrow().total_retain > model.borrow().fs_capacity; + list.add_child( + "total", + views::LinearLayout::horizontal() + .child(views::TextView::new(encode_size(model.borrow().total_used)).fixed_width(25)) + .child(views::TextView::new(encode_size(model.borrow().total_retain)) + .with_id("total_retain").fixed_width(25)) + .child(views::TextView::new(if over { "*" } else { " " }).with_id("total_ok"))); + list.add_child( + "filesystem", + views::LinearLayout::horizontal() + .child(views::TextView::new("").fixed_width(25)) + .child(views::TextView::new(encode_size(model.borrow().fs_capacity)).fixed_width(25))); + let mut change_button = views::Button::new("Change", { + let model = model.clone(); + move |siv| press_change(&model, siv) + }); + change_button.set_enabled(!over); + let mut buttons = views::LinearLayout::horizontal() + .child(views::DummyView.full_width()); + buttons.add_child(change_button.with_id("change")); + buttons.add_child(views::DummyView); + buttons.add_child(views::Button::new("Cancel", |siv| siv.screen_mut().pop_layer())); + siv.add_layer( + views::Dialog::around( + views::LinearLayout::vertical() + .child(list) + .child(views::DummyView) + .child(buttons)) + .title("Edit retention")); +} diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index de6667e..b9c7ba6 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -40,6 +40,7 @@ use slog_term; use std::path::Path; mod check; +mod config; mod init; mod run; mod ts; @@ -48,6 +49,7 @@ mod upgrade; #[derive(Debug, RustcDecodable)] pub enum Command { Check, + Config, Init, Run, Ts, @@ -58,6 +60,7 @@ impl Command { pub fn run(&self) -> Result<(), Error> { match *self { Command::Check => check::run(), + Command::Config => config::run(), Command::Init => init::run(), Command::Run => run::run(), Command::Ts => ts::run(), @@ -71,7 +74,7 @@ impl Command { /// Sync logging should be preferred for other modes because async apparently is never flushed /// before the program exits, and partial output from these tools is very confusing. fn install_logger(async: bool) { - let drain = slog_term::StreamerBuilder::new(); + let drain = slog_term::StreamerBuilder::new().stderr(); let drain = slog_envlogger::new(if async { drain.async() } else { drain }.full().build()); slog_stdlog::set_logger(slog::Logger::root(drain.ignore_err(), None)).unwrap(); } diff --git a/src/db.rs b/src/db.rs index 64ee75d..414d38a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -342,6 +342,18 @@ pub struct Camera { next_recording_id: i32, } +/// Information about a camera, used by `add_camera` and `update_camera`. +#[derive(Debug)] +pub struct CameraChange { + pub short_name: String, + pub description: String, + pub host: String, + pub username: String, + pub password: String, + pub main_rtsp_path: String, + pub sub_rtsp_path: String, +} + /// Adds `delta` to the day represented by `day` in the map `m`. /// Inserts a map entry if absent; removes the entry if it has 0 entries on exit. fn adjust_day(day: CameraDayKey, delta: CameraDayValue, @@ -527,6 +539,9 @@ struct CameraModification { /// Reset the next_recording_id to the specified value. new_next_recording_id: Option, + + /// Reset the retain_bytes to the specified value. + new_retain_bytes: Option, } fn composite_id(camera_id: i32, recording_id: i32) -> i64 { @@ -667,6 +682,26 @@ impl<'a> Transaction<'a> { Ok(()) } + /// Updates the `retain_bytes` for the given camera to the specified limit. + /// Note this just resets the limit in the database; it's the caller's responsibility to ensure + /// current usage is under the new limit if desired. + pub fn update_retention(&mut self, camera_id: i32, new_limit: i64) -> Result<(), Error> { + if new_limit < 0 { + return Err(Error::new(format!("can't set limit for camera {} to {}; must be >= 0", + camera_id, new_limit))); + } + self.check_must_rollback()?; + let mut stmt = + self.tx.prepare_cached("update camera set retain_bytes = :retain where id = :id")?; + let changes = stmt.execute_named(&[(":retain", &new_limit), (":id", &camera_id)])?; + if changes != 1 { + return Err(Error::new(format!("no such camera {}", camera_id))); + } + let mut m = Transaction::get_mods_by_camera(&mut self.mods_by_camera, camera_id); + m.new_retain_bytes = Some(new_limit); + Ok(()) + } + /// Commits these changes, consuming the Transaction. pub fn commit(mut self) -> Result<(), Error> { self.check_must_rollback()?; @@ -684,6 +719,9 @@ impl<'a> Transaction<'a> { if let Some(id) = m.new_next_recording_id { camera.next_recording_id = id; } + if let Some(b) = m.new_retain_bytes { + camera.retain_bytes = b; + } } Ok(()) } @@ -706,6 +744,7 @@ impl<'a> Transaction<'a> { range: None, days: BTreeMap::new(), new_next_recording_id: None, + new_retain_bytes: None, } }) } @@ -1103,6 +1142,102 @@ impl LockedDatabase { Ok(id) } + + /// Adds a camera. + pub fn add_camera(&mut self, camera: CameraChange) -> Result { + let uuid = Uuid::new_v4(); + let uuid_bytes = &uuid.as_bytes()[..]; + let mut stmt = self.conn.prepare_cached(r#" + insert into camera (uuid, short_name, description, host, username, password, + main_rtsp_path, sub_rtsp_path, retain_bytes, next_recording_id) + values (:uuid, :short_name, :description, :host, :username, :password, + :main_rtsp_path, :sub_rtsp_path, 0, 1) + "#)?; + stmt.execute_named(&[ + (":uuid", &uuid_bytes), + (":short_name", &camera.short_name), + (":description", &camera.description), + (":host", &camera.host), + (":username", &camera.username), + (":password", &camera.password), + (":main_rtsp_path", &camera.main_rtsp_path), + (":sub_rtsp_path", &camera.sub_rtsp_path), + ])?; + let id = self.conn.last_insert_rowid() as i32; + self.state.cameras_by_id.insert(id, Camera{ + id: id, + uuid: uuid, + short_name: camera.short_name, + description: camera.description, + host: camera.host, + username: camera.username, + password: camera.password, + main_rtsp_path: camera.main_rtsp_path, + sub_rtsp_path: camera.sub_rtsp_path, + retain_bytes: 0, + range: None, + sample_file_bytes: 0, + duration: recording::Duration(0), + days: BTreeMap::new(), + next_recording_id: 1, + }); + self.state.cameras_by_uuid.insert(uuid, id); + Ok(id) + } + + /// Updates a camera. + pub fn update_camera(&mut self, id: i32, camera: CameraChange) -> Result<(), Error> { + let mut stmt = self.conn.prepare_cached(r#" + update camera set + short_name = :short_name, + description = :description, + host = :host, + username = :username, + password = :password, + main_rtsp_path = :main_rtsp_path, + sub_rtsp_path = :sub_rtsp_path + where + id = :id + "#)?; + stmt.execute_named(&[ + (":id", &id), + (":short_name", &camera.short_name), + (":description", &camera.description), + (":host", &camera.host), + (":username", &camera.username), + (":password", &camera.password), + (":main_rtsp_path", &camera.main_rtsp_path), + (":sub_rtsp_path", &camera.sub_rtsp_path), + ])?; + let c = self.state.cameras_by_id.get_mut(&id).unwrap(); + c.short_name = camera.short_name; + c.description = camera.description; + c.host = camera.host; + c.username = camera.username; + c.password = camera.password; + c.main_rtsp_path = camera.main_rtsp_path; + c.sub_rtsp_path = camera.sub_rtsp_path; + Ok(()) + } + + /// Deletes a camera. The camera must have no recordings. + pub fn delete_camera(&mut self, id: i32) -> Result<(), Error> { + let (has_recordings, uuid) = + self.state.cameras_by_id.get(&id) + .map(|c| (c.range.is_some(), c.uuid)) + .ok_or_else(|| Error::new(format!("No such camera {} to remove", id)))?; + if has_recordings { + return Err(Error::new(format!("Can't remove camera {}; has recordings.", id))); + }; + let mut stmt = self.conn.prepare_cached(r"delete from camera where id = :id")?; + let rows = stmt.execute_named(&[(":id", &id)])?; + if rows != 1 { + return Err(Error::new(format!("Camera {} missing from database", id))); + } + self.state.cameras_by_id.remove(&id); + self.state.cameras_by_uuid.remove(&uuid); + return Ok(()) + } } /// Gets the schema version from the given database connection. diff --git a/src/dir.rs b/src/dir.rs index a220b1e..20fc830 100644 --- a/src/dir.rs +++ b/src/dir.rs @@ -43,7 +43,7 @@ use std::fs; use std::io::{self, Write}; use std::mem; use std::os::unix::io::FromRawFd; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex}; use std::sync::mpsc; use std::thread; use uuid::Uuid; @@ -98,6 +98,16 @@ impl Fd { } Ok(()) } + + pub fn statfs(&self) -> Result { + unsafe { + let mut stat: libc::statvfs = mem::zeroed(); + if libc::fstatvfs(self.0, &mut stat) < 0 { + return Err(io::Error::last_os_error()) + } + Ok(stat) + } + } } impl SampleFileDir { @@ -153,6 +163,8 @@ impl SampleFileDir { Writer::open(f, uuid, prev, camera_id, video_sample_entry_id, channel) } + pub fn statfs(&self) -> Result { self.fd.statfs() } + /// Opens a sample file within this directory with the given flags and (if creating) mode. fn open_int(&self, uuid: Uuid, flags: libc::c_int, mode: libc::c_int) -> Result { @@ -215,11 +227,10 @@ enum SyncerCommand { pub struct SyncerChannel(mpsc::Sender); /// State of the worker thread. -struct SyncerState { +struct Syncer { dir: Arc, to_unlink: Vec, to_mark_deleted: Vec, - cmds: mpsc::Receiver, } /// Starts a syncer for the given sample file directory. @@ -234,15 +245,71 @@ pub fn start_syncer(dir: Arc) -> Result<(SyncerChannel, thread::JoinHandle<()>), Error> { let to_unlink = dir.db.lock().list_reserved_sample_files()?; let (snd, rcv) = mpsc::channel(); - let mut state = SyncerState { + let mut syncer = Syncer { dir: dir, to_unlink: to_unlink, to_mark_deleted: Vec::new(), - cmds: rcv, }; - state.initial_rotation()?; + syncer.initial_rotation()?; Ok((SyncerChannel(snd), - thread::Builder::new().name("syncer".into()).spawn(move || state.run()).unwrap())) + thread::Builder::new().name("syncer".into()).spawn(move || syncer.run(rcv)).unwrap())) +} + +pub struct NewLimit { + pub camera_id: i32, + pub limit: i64, +} + +/// Deletes recordings if necessary to fit within the given new `retain_bytes` limit. +/// Note this doesn't change the limit in the database; it only deletes files. +/// Pass a limit of 0 to delete all recordings associated with a camera. +pub fn lower_retention(dir: Arc, limits: &[NewLimit]) -> Result<(), Error> { + let to_unlink = dir.db.lock().list_reserved_sample_files()?; + let mut syncer = Syncer { + dir: dir, + to_unlink: to_unlink, + to_mark_deleted: Vec::new(), + }; + syncer.do_rotation(|db| { + let mut to_delete = Vec::new(); + for l in limits { + let before = to_delete.len(); + let camera = db.cameras_by_id().get(&l.camera_id) + .ok_or_else(|| Error::new(format!("no such camera {}", l.camera_id)))?; + if l.limit >= camera.sample_file_bytes { continue } + get_rows_to_delete(db, l.camera_id, camera, camera.retain_bytes - l.limit, + &mut to_delete)?; + info!("camera {}, {}->{}, deleting {} rows", camera.short_name, + camera.sample_file_bytes, l.limit, to_delete.len() - before); + } + Ok(to_delete) + }) +} + +/// Gets rows to delete to bring a camera's disk usage within bounds. +fn get_rows_to_delete(db: &db::LockedDatabase, camera_id: i32, + camera: &db::Camera, extra_bytes_needed: i64, + to_delete: &mut Vec) -> Result<(), Error> { + let bytes_needed = camera.sample_file_bytes + extra_bytes_needed - camera.retain_bytes; + let mut bytes_to_delete = 0; + if bytes_needed <= 0 { + debug!("{}: have remaining quota of {}", camera.short_name, -bytes_needed); + return Ok(()); + } + let mut n = 0; + db.list_oldest_sample_files(camera_id, |row| { + bytes_to_delete += row.sample_file_bytes as i64; + to_delete.push(row); + n += 1; + bytes_needed > bytes_to_delete // continue as long as more deletions are needed. + })?; + if bytes_needed > bytes_to_delete { + return Err(Error::new(format!("{}: couldn't find enough files to delete: {} left.", + camera.short_name, bytes_needed))); + } + info!("{}: deleting {} bytes in {} recordings ({} bytes needed)", + camera.short_name, bytes_to_delete, n, bytes_needed); + Ok(()) } impl SyncerChannel { @@ -265,10 +332,10 @@ impl SyncerChannel { } } -impl SyncerState { - fn run(&mut self) { +impl Syncer { + fn run(&mut self, cmds: mpsc::Receiver) { loop { - match self.cmds.recv() { + match cmds.recv() { Err(_) => return, // all senders have closed the channel; shutdown Ok(SyncerCommand::AsyncSaveRecording(recording, f)) => self.save(recording, f), Ok(SyncerCommand::AsyncAbandonRecording(uuid)) => self.abandon(uuid), @@ -280,16 +347,25 @@ impl SyncerState { /// Rotates files for all cameras and deletes stale reserved uuids from previous runs. fn initial_rotation(&mut self) -> Result<(), Error> { - let mut to_delete = Vec::new(); - { - let mut db = self.dir.db.lock(); + self.do_rotation(|db| { + let mut to_delete = Vec::new(); for (camera_id, camera) in db.cameras_by_id() { - self.get_rows_to_delete(&db, *camera_id, camera, 0, &mut to_delete)?; + get_rows_to_delete(&db, *camera_id, camera, 0, &mut to_delete)?; } + Ok(to_delete) + }) + } + + fn do_rotation(&mut self, get_rows_to_delete: F) -> Result<(), Error> + where F: FnOnce(&db::LockedDatabase) -> Result, Error> { + let to_delete = { + let mut db = self.dir.db.lock(); + let to_delete = get_rows_to_delete(&*db)?; let mut tx = db.tx()?; tx.delete_recordings(&to_delete)?; tx.commit()?; - } + to_delete + }; for row in to_delete { self.to_unlink.push(row.uuid); } @@ -345,8 +421,8 @@ impl SyncerState { let camera = db.cameras_by_id().get(&recording.camera_id) .ok_or_else(|| Error::new(format!("no such camera {}", recording.camera_id)))?; - self.get_rows_to_delete(&db, recording.camera_id, camera, - recording.sample_file_bytes as i64, &mut to_delete)?; + get_rows_to_delete(&db, recording.camera_id, camera, + recording.sample_file_bytes as i64, &mut to_delete)?; } let mut tx = db.tx()?; tx.mark_sample_files_deleted(&self.to_mark_deleted)?; @@ -363,32 +439,6 @@ impl SyncerState { Ok(()) } - /// Gets rows to delete to bring a camera's disk usage within bounds. - fn get_rows_to_delete(&self, db: &MutexGuard, camera_id: i32, - camera: &db::Camera, extra_bytes_needed: i64, - to_delete: &mut Vec) -> Result<(), Error> { - let bytes_needed = camera.sample_file_bytes + extra_bytes_needed - camera.retain_bytes; - let mut bytes_to_delete = 0; - if bytes_needed <= 0 { - debug!("{}: have remaining quota of {}", camera.short_name, -bytes_needed); - return Ok(()); - } - let mut n = 0; - db.list_oldest_sample_files(camera_id, |row| { - bytes_to_delete += row.sample_file_bytes as i64; - to_delete.push(row); - n += 1; - bytes_needed > bytes_to_delete // continue as long as more deletions are needed. - })?; - if bytes_needed > bytes_to_delete { - return Err(Error::new(format!("{}: couldn't find enough files to delete: {} left.", - camera.short_name, bytes_needed))); - } - info!("{}: deleting {} bytes in {} recordings ({} bytes needed)", - camera.short_name, bytes_to_delete, n, bytes_needed); - Ok(()) - } - /// Tries to unlink all the uuids in `self.to_unlink`. Any which can't be unlinked will /// be retained in the vec. fn try_unlink(&mut self) { diff --git a/src/main.rs b/src/main.rs index 3ffeffa..81157cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -93,6 +93,7 @@ Commands: check Check database integrity init Initialize a database run Run the daemon: record from cameras and handle HTTP requests + shell Start an interactive shell to modify the database ts Translate between human-readable and numeric timestamps upgrade Upgrade the database to the latest schema ";