diff --git a/.gitignore b/.gitignore index 09a8f25..101a285 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ cameras.sql .project .settings *.swp -target +node_modules prep.config +target +ui-dist diff --git a/Cargo.lock b/Cargo.lock index 240f2e5..7a9ea16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,7 +7,9 @@ dependencies = [ "docopt 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", "fnv 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", "futures 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)", - "http-entity 0.0.1 (git+https://github.com/scottlamb/http-entity)", + "futures-cpupool 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "http-entity 0.0.1", + "http-file 0.0.1", "hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.31 (registry+https://github.com/rust-lang/crates.io-index)", @@ -221,7 +223,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "http-entity" version = "0.0.1" -source = "git+https://github.com/scottlamb/http-entity#d8a51ae8602a1b80dc757fd4a404fc878f599ca7" dependencies = [ "futures 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)", "hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -230,6 +231,20 @@ dependencies = [ "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "http-file" +version = "0.0.1" +dependencies = [ + "futures 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)", + "futures-cpupool 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "http-entity 0.0.1", + "hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.31 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "mime 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "httparse" version = "1.2.3" @@ -1147,7 +1162,6 @@ dependencies = [ "checksum futures 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "05a23db7bd162d4e8265968602930c476f688f0c180b44bdaf55e0cb2c687558" "checksum futures-cpupool 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "77d49e7de8b91b20d6fda43eea906637eff18b96702eb6b2872df8bfab1ad2b5" "checksum gcc 0.3.54 (registry+https://github.com/rust-lang/crates.io-index)" = "5e33ec290da0d127825013597dbdfc28bee4964690c7ce1166cbc2a7bd08b1bb" -"checksum http-entity 0.0.1 (git+https://github.com/scottlamb/http-entity)" = "" "checksum httparse 1.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "af2f2dd97457e8fb1ae7c5a420db346af389926e36f43768b96f101546b04a07" "checksum hyper 0.11.2 (git+https://github.com/scottlamb/hyper?branch=moonfire-on-0.11.x)" = "" "checksum hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)" = "641abc3e3fcf0de41165595f801376e01106bca1fd876dda937730e477ca004c" diff --git a/Cargo.toml b/Cargo.toml index be18e69..abb7e89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,12 @@ bundled = ["rusqlite/bundled"] byteorder = "1.0" docopt = "0.8" futures = "0.1" +futures-cpupool = "0.1" fnv = "1.0" -http-entity = { git = "https://github.com/scottlamb/http-entity" } +http-entity = { path = "../http-entity" } +http-file = { path = "../http-entity/http-file" } +#http-entity = { git = "https://github.com/scottlamb/http-entity" } +#http-file = { git = "https://github.com/scottlamb/http-entity" } hyper = "0.11.2" lazy_static = "0.2" libc = "0.2" diff --git a/README.md b/README.md index 2c4654e..d34b255 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ analyze, or re-encode video frames, so it requires little CPU. It handles six 2](https://www.raspberrypi.org/products/raspberry-pi-2-model-b/), using less than 10% of the machine's total CPU. -So far, the web interface is basic: just a table with links to one-hour -segments of video. Although the backend supports generating `.mp4` files for -arbitrary time ranges, you have to construct URLs by hand. There's also no -support for motion detection, no authentication, and no config UI. +So far, the web interface is basic: a filterable list of video segments, +with support for trimming them to arbitrary time ranges. No scrub bar yet. +There's also no support for motion detection, no authentication, and no config +UI. + +[![screenshot](screenshot-smaller.png | 704x474)](screenshot.png) This is version 0.1, the initial release. Until version 1.0, there will be no compatibility guarantees: configuration and storage formats may change from diff --git a/design/api.md b/design/api.md index 5787525..60a689e 100644 --- a/design/api.md +++ b/design/api.md @@ -22,31 +22,57 @@ HTML rather than JSON. TODO(slamb): authentication. -### `/cameras/` +### `/api/` -A `GET` request on this URL returns basic information about all cameras. The -`application/json` response will have a top-level `cameras` with a list of -attributes about each camera: +A `GET` request on this URL returns basic information about the server, +including all cameras. Valid request parameters: -* `uuid`: in text format -* `shortName`: a short name (typically one or two words) -* `description`: a longer description (typically a phrase or paragraph) -* `retainBytes`: the configured total number of bytes of completed - recordings to retain. -* `minStartTime90k`: the start time of the earliest recording for this - camera, in 90kHz units since 1970-01-01 00:00:00 UTC. -* `maxEndTime90k`: the end time of the latest recording for this - camera, in 90kHz units since 1970-01-01 00:00:00 UTC. -* `totalDuration90k`: the total duration recorded, in 90 kHz units. - This is no greater than `maxEndTime90k - maxStartTime90k`; it - will be lesser if there are gaps in the recorded data. -* `totalSampleFileBytes`: the total number of bytes of sample data (the - `mdat` portion of a `.mp4` file). +* `days`: a boolean indicating if the days parameter described below + should be included. + +Example request URI: + +``` +/api/?days=true +``` + +The `application/json` response will have a dict as follows: + +* `timeZoneName`: the name of the IANA time zone the server is using + to divide recordings into days as described further below. +* `cameras`: a list of cameras. Each is a dict as follows: + * `uuid`: in text format + * `shortName`: a short name (typically one or two words) + * `description`: a longer description (typically a phrase or paragraph) + * `retainBytes`: the configured total number of bytes of completed + recordings to retain. + * `minStartTime90k`: the start time of the earliest recording for this + camera, in 90kHz units since 1970-01-01 00:00:00 UTC. + * `maxEndTime90k`: the end time of the latest recording for this camera, + in 90kHz units since 1970-01-01 00:00:00 UTC. + * `totalDuration90k`: the total duration recorded, in 90 kHz units. + This is no greater than `maxEndTime90k - maxStartTime90k`; it will be + lesser if there are gaps in the recorded data. + * `totalSampleFileBytes`: the total number of bytes of sample data (the + `mdat` portion of a `.mp4` file). + * `days`: object representing calendar days (in the server's time zone) + with non-zero total duration of recordings for that day. The keys are + of the form `YYYY-mm-dd`; the values are objects with the following + attributes: + * `totalDuration90k` is the total duration recorded during that day. + If a recording spans a day boundary, some portion of it is accounted to + each day. + * `startTime90k` is the start of that calendar day in the server's time + zone. + * `endTime90k` is the end of that calendar day in the server's time zone. + It is usually 24 hours after the start time. It might be 23 hours or 25 + hours during spring forward or fall back, respectively. Example response: ```json { + "timeZoneName": "America/Los_Angeles", "cameras": [ { "uuid": "fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe", @@ -57,31 +83,27 @@ Example response: "maxEndTime90k": 130985466591817, "totalDuration90k": 96736169725, "totalSampleFileBytes": 446774393937, + "days": { + "2016-05-01": { + "endTime90k": 131595516000000, + "startTime90k": 131587740000000, + "totalDuration90k": 52617609 + }, + "2016-05-02": { + "endTime90k": 131603292000000, + "startTime90k": 131595516000000, + "totalDuration90k": 20946022 + } + }, }, ... ], } ``` -### `/cameras//` +### `/api/cameras//` A GET returns information for the camera with the given URL. The information -returned is a superset of that returned by the camera list. It also includes a -list of calendar days (in the server's time zone) with data in the server's -time zone. The `days` entry is a object mapping `YYYY-mm-dd` to a day object -with the following attributes: - -* `totalDuration90k` is the total duration recorded during that day. - If a recording spans a day boundary, some portion of it is accounted to - each day. -* `startTime90k` is the start of that calendar day in the server's time - zone. -* `endTime90k` is the end of that calendar day in the server's time zone. - It is usually 24 hours after the start time. It might be 23 hours or 25 - hours during spring forward or fall back, respectively. - -A calendar day will be present in the `days` object iff there is a non-zero -total duration of recordings for that day. Example response: @@ -99,17 +121,17 @@ Example response: "totalDuration90k": 20946022 } }, - "description":"", + "description": "", "maxEndTime90k": 131598273666690, "minStartTime90k": 131590386129355, "retainBytes": 104857600, "shortName": "driveway", "totalDuration90k": 73563631, - "totalSampleFileBytes": 98901406, + "totalSampleFileBytes": 98901406 } ``` -### `/camera//recordings` +### `/api/cameras//recordings` A GET returns information about recordings, in descending order. @@ -153,7 +175,7 @@ Each recording object has the following properties: Example request URI (with added whitespace between parameters): ``` -/camera/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/recordings +/api/cameras/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/recordings ?startTime90k=130888729442361 &endTime90k=130985466591817 ``` @@ -182,7 +204,7 @@ Example response: } ``` -### `/camera//view.mp4` +### `/api/cameras//view.mp4` A GET returns a `.mp4` file, with an etag and support for range requests. The MIME type will be `video/mp4`, with a `codecs` parameter as specified in [RFC @@ -208,27 +230,27 @@ Expected query parameters: Example request URI to retrieve all of recording id 1 from the given camera: ``` - /camera/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/view.mp4?s=1 + /api/cameras/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/view.mp4?s=1 ``` Example request URI to retrieve all of recording ids 1–5 from the given camera, with timestamp subtitles: ``` - /camera/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/view.mp4?s=1-5&ts=true + /api/cameras/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/view.mp4?s=1-5&ts=true ``` Example request URI to retrieve recording id 1, skipping its first 26 90,000ths of a second: ``` - /camera/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/view.mp4?s=1.26 + /api/cameras/fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe/view.mp4?s=1.26 ``` TODO: error behavior on missing segment. It should be a 404, likely with an `application/json` body describing what portion if any (still) exists. -### `/camera//view.m4s` +### `/api/cameras//view.m4s` A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions media segment][media-segment]. The MIME type will be `video/mp4`, with a @@ -254,7 +276,7 @@ recording segment for several reasons: than one video sample entry, so a `.m4s` that uses more than one video sample entry can't be used. -### `/init/.mp4` +### `/api/init/.mp4` A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions initialization segment][init-segment]. The MIME type will be `video/mp4`, with diff --git a/guide/install.md b/guide/install.md index 1dbf0d6..6248e9e 100644 --- a/guide/install.md +++ b/guide/install.md @@ -17,10 +17,7 @@ from source. Moonfire NVR is written in the [Rust Programming Language](https://www.rust-lang.org/en-US/). In the long term, I expect this -will result in a more secure, full-featured, easy-to-install software. In the -short term, there will be growing pains. Rust is a new programming language. -Moonfire NVR's primary author is new to Rust. And Moonfire NVR is a young -project. +will result in a more secure, full-featured, easy-to-install software. You will need the following C libraries installed: @@ -55,6 +52,8 @@ all non-Rust dependencies: Next, you need Rust 1.17+ and Cargo. The easiest way to install them is by following the instructions at [rustup.rs](https://www.rustup.rs/). +Finally, building the UI requires [yarn](https://yarnpkg.com/en/). + You can continue to follow the build/install instructions below for a manual build and install, or alternatively you can run the prep script called `prep.sh`. @@ -85,9 +84,12 @@ For instructions, you can skip to "[Camera configuration and hard disk mounting] Once prerequisites are installed, Moonfire NVR can be built as follows: + $ yarn build $ cargo test $ cargo build --release $ sudo install -m 755 target/release/moonfire-nvr /usr/local/bin + $ sudo mkdir /usr/local/lib/moonfire-nvr + $ sudo cp -R ui-dist /usr/local/lib/moonfire-nvr/ui ## Further configuration diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7c0b77 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "author": "Scott Lamb ", + "bugs": { + "url": "https://github.com/scottlamb/moonfire-nvr/issues" + }, + "scripts": { + "build": "ln ui-src/index.html ui-dist && webpack" + }, + "dependencies": { + "jquery": "^3.2.1", + "jquery-ui": "^1.12.1", + "moment-timezone": "^0.5.13" + }, + "homepage": "https://github.com/scottlamb/moonfire-nvr", + "license": "GPL-3.0", + "name": "moonfire-nvr", + "repository": "scottlamb/moonfire-nvr", + "version": "0.1.0", + "devDependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-minify-webpack-plugin": "^0.2.0", + "babel-preset-env": "^1.6.1", + "css-loader": "^0.28.7", + "file-loader": "^1.1.5", + "style-loader": "^0.19.0", + "webpack": "^3.8.1" + } +} diff --git a/prep.sh b/prep.sh index eac01bd..d12efa4 100755 --- a/prep.sh +++ b/prep.sh @@ -76,6 +76,10 @@ fi # #SERVICE_BIN= +# Resource files location +# Default: "/usr/local/lib/moonfire-nvr" +#LIB_DIR=/usr/local/lib/moonfire-nvr + # Service name # Default: "moonfire-nvr" # @@ -111,6 +115,7 @@ SAMPLES_DIR="${SAMPLES_DIR:-$NVR_HOME/$SAMPLES_DIR_NAME}" SERVICE_NAME="${SERVICE_NAME:-moonfire-nvr}" SERVICE_DESC="${SERVICE_DESC:-Moonfire NVR}" SERVICE_BIN="${SERVICE_BIN:-/usr/local/bin/moonfire-nvr}" +LIB_DIR="${UI_DIR:-/usr/local/lib/moonfire-nvr}" # Process command line options # @@ -150,7 +155,7 @@ if [ ! -x "${SERVICE_BIN}" ]; then echo "Binary not installed, building..."; echo if ! cargo --version; then echo "cargo not installed/working." - echo "Install a nightly Rust (see http://rustup.us) first." + echo "Install Rust (see http://rustup.us) first." echo exit 1 fi @@ -161,7 +166,7 @@ if [ ! -x "${SERVICE_BIN}" ]; then exit 1 fi if ! cargo build --release; then - echo "Build failed." + echo "Server build failed." echo "RUST_TEST_THREADS=1 cargo build --release --verbose" echo exit 1 @@ -174,6 +179,23 @@ if [ ! -x "${SERVICE_BIN}" ]; then exit 1 fi fi +if [ ! -d "${LIB_DIR}/ui" ]; then + echo "UI directory doesn't exist, building..."; echo + if ! yarn --version; then + echo "yarn not installed/working." + echo "Install from https://yarnpkg.com/ first." + echo + exit 1 + fi + if ! yarn build; then + echo "UI build failed." + echo "yarn build" + echo + exit 1 + fi + sudo mkdir "${LIB_DIR}" + sudo cp -R ui-dist "${LIB_DIR}/ui" +fi # Create user and groups # @@ -224,6 +246,7 @@ After=network-online.target ExecStart=${SERVICE_BIN} run \\ --sample-file-dir=${SAMPLES_PATH} \\ --db-dir=${DB_DIR} \\ + --ui-dir=${LIB_DIR}/ui \\ --http-addr=0.0.0.0:${NVR_PORT} Environment=TZ=:/etc/localtime Environment=MOONFIRE_FORMAT=google-systemd diff --git a/screenshot-small.png b/screenshot-small.png new file mode 100644 index 0000000..197bba3 Binary files /dev/null and b/screenshot-small.png differ diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..197bba3 Binary files /dev/null and b/screenshot.png differ diff --git a/src/cmds/run.rs b/src/cmds/run.rs index bd53015..bbeb2bd 100644 --- a/src/cmds/run.rs +++ b/src/cmds/run.rs @@ -53,6 +53,8 @@ Options: --sample-file-dir=DIR Set the directory holding video data. This is typically on a hard drive. [default: /var/lib/moonfire-nvr/sample] + --ui-dir=DIR Set the directory with the user interface files (.html, .js, etc). + [default: /usr/local/lib/moonfire-nvr/ui] --http-addr=ADDR Set the bind address for the unencrypted HTTP server. [default: 0.0.0.0:8080] --read-only Forces read-only mode / disables recording. @@ -63,6 +65,7 @@ struct Args { flag_db_dir: String, flag_sample_file_dir: String, flag_http_addr: String, + flag_ui_dir: String, flag_read_only: bool, } @@ -83,6 +86,8 @@ pub fn run() -> Result<(), Error> { let dir = dir::SampleFileDir::new(&args.flag_sample_file_dir, db.clone()).unwrap(); info!("Database is loaded."); + let s = web::Service::new(db.clone(), dir.clone(), Some(&args.flag_ui_dir))?; + // Start a streamer for each camera. let shutdown_streamers = Arc::new(AtomicBool::new(false)); let mut streamers = Vec::new(); @@ -113,7 +118,7 @@ pub fn run() -> Result<(), Error> { // Start the web interface. let addr = args.flag_http_addr.parse().unwrap(); let server = ::hyper::server::Http::new() - .bind(&addr, move || Ok(web::Service::new(db.clone(), dir.clone()))) + .bind(&addr, move || Ok(s.clone())) .unwrap(); let shutdown = setup_shutdown_future(&server.handle()); diff --git a/src/json.rs b/src/json.rs index 8263ca9..d6f8cfa 100644 --- a/src/json.rs +++ b/src/json.rs @@ -35,9 +35,10 @@ use uuid::Uuid; #[derive(Debug, Serialize)] pub struct ListCameras<'a> { - // Use a custom serializer which presents the map's values as a sequence. + // Use a custom serializer which presents the map's values as a sequence and includes the + // "days" attribute or not, according to the bool in the tuple. #[serde(serialize_with = "ListCameras::serialize_cameras")] - pub cameras: &'a BTreeMap, + pub cameras: (&'a BTreeMap, bool), } /// JSON serialization wrapper for a single camera when processing `/cameras/` and @@ -106,12 +107,12 @@ struct CameraDayValue { impl<'a> ListCameras<'a> { /// Serializes cameras as a list (rather than a map), wrapping each camera in the /// `ListCamerasCamera` type to tweak the data returned. - fn serialize_cameras(cameras: &BTreeMap, + fn serialize_cameras(cameras: &(&BTreeMap, bool), serializer: S) -> Result where S: Serializer { - let mut seq = serializer.serialize_seq(Some(cameras.len()))?; - for c in cameras.values() { - seq.serialize_element(&Camera::new(c, false))?; + let mut seq = serializer.serialize_seq(Some(cameras.0.len()))?; + for c in cameras.0.values() { + seq.serialize_element(&Camera::new(c, cameras.1))?; } seq.end() } diff --git a/src/main.rs b/src/main.rs index 88f8f37..ff2b95c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,8 +34,10 @@ extern crate byteorder; extern crate core; extern crate docopt; extern crate futures; +extern crate futures_cpupool; extern crate fnv; extern crate http_entity; +extern crate http_file; extern crate hyper; #[macro_use] extern crate lazy_static; extern crate libc; diff --git a/src/web.rs b/src/web.rs index 94e35ea..49845af 100644 --- a/src/web.rs +++ b/src/web.rs @@ -36,31 +36,29 @@ use db; use dir::SampleFileDir; use error::Error; use futures::{future, stream}; +use futures_cpupool; use json; use http_entity; +use http_file; use hyper::header; use hyper::server::{self, Request, Response}; use mime; use mp4; -use parking_lot::MutexGuard; use recording; use reffers::ARefs; use regex::Regex; use serde_json; use slices; +use std::collections::HashMap; use std::cmp; -use std::fmt; -use std::io::Write; +use std::fs; use std::ops::Range; +use std::path::PathBuf; use std::sync::Arc; use strutil; -use time; use url::form_urlencoded; use uuid::Uuid; -const BINARY_PREFIXES: &'static [&'static str] = &[" ", " Ki", " Mi", " Gi", " Ti", " Pi", " Ei"]; -const DECIMAL_PREFIXES: &'static [&'static str] =&[" ", " k", " M", " G", " T", " P", " E"]; - lazy_static! { /// Regex used to parse the `s` query parameter to `view.mp4`. /// As described in `design/api.md`, this is of the form @@ -69,18 +67,23 @@ lazy_static! { } enum Path { - CamerasList, // "/" or "/cameras/" - InitSegment([u8; 20]), // "/init/.mp4" - Camera(Uuid), // "/cameras//" - CameraRecordings(Uuid), // "/cameras//recordings" - CameraViewMp4(Uuid), // "/cameras//view.mp4" - CameraViewMp4Segment(Uuid), // "/cameras//view.m4s" + TopLevel, // "/api/" + InitSegment([u8; 20]), // "/api/init/.mp4" + Camera(Uuid), // "/api/cameras//" + CameraRecordings(Uuid), // "/api/cameras//recordings" + CameraViewMp4(Uuid), // "/api/cameras//view.mp4" + CameraViewMp4Segment(Uuid), // "/api/cameras//view.m4s" + Static, // "" NotFound, } fn decode_path(path: &str) -> Path { + if !path.starts_with("/api/") { + return Path::Static; + } + let path = &path["/api".len()..]; if path == "/" { - return Path::CamerasList; + return Path::TopLevel; } if path.starts_with("/init/") { if path.len() != 50 || !path.ends_with(".mp4") { @@ -95,9 +98,6 @@ fn decode_path(path: &str) -> Path { return Path::NotFound; } let path = &path["/cameras/".len()..]; - if path == "" { - return Path::CamerasList; - } let slash = match path.find('/') { None => { return Path::NotFound; }, Some(s) => s, @@ -118,72 +118,6 @@ fn decode_path(path: &str) -> Path { } } -fn is_json(req: &Request) -> bool { - if let Some(accept) = req.headers().get::() { - return accept.len() == 1 && accept[0].item == mime::APPLICATION_JSON && - accept[0].quality == header::q(1000); - } - false -} - -pub struct HtmlEscaped<'a>(&'a str); - -impl<'a> fmt::Display for HtmlEscaped<'a> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let mut last_end = 0; - for (start, part) in self.0.match_indices(|c| c == '<' || c == '&') { - f.write_str(&self.0[last_end..start])?; - f.write_str(if part == "<" { "<" } else { "&" })?; - last_end = start + 1; - } - f.write_str(&self.0[last_end..]) - } -} - -pub struct Humanized(i64); - -impl Humanized { - fn do_fmt(&self, base: f32, prefixes: &[&str], f: &mut fmt::Formatter) -> fmt::Result { - let mut n = self.0 as f32; - let mut i = 0; - loop { - if n < base || i >= prefixes.len() - 1 { - break; - } - n /= base; - i += 1; - } - write!(f, "{:.1}{}", n, prefixes[i]) - } -} - -impl fmt::Display for Humanized { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - self.do_fmt(1000., DECIMAL_PREFIXES, f) - } -} - -impl fmt::Binary for Humanized { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - self.do_fmt(1024., BINARY_PREFIXES, f) - } -} - -pub struct HumanizedTimestamp(Option); - -impl fmt::Display for HumanizedTimestamp { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self.0 { - None => f.write_str("n/a"), - Some(t) => { - let tm = time::at(time::Timespec{sec: t.unix_seconds(), nsec: 0}); - write!(f, "{}", - tm.strftime("%a, %d %b %Y %H:%M:%S %Z").or_else(|_| Err(fmt::Error))?) - } - } - } -} - #[derive(Debug, Eq, PartialEq)] struct Segments { ids: Range, @@ -224,16 +158,23 @@ impl Segments { } } -pub struct Service { - db: Arc, - dir: Arc, +/// A user interface file (.html, .js, etc). +/// The list of files is loaded into the server at startup; this makes path canonicalization easy. +/// The files themselves are opened on every request so they can be changed during development. +#[derive(Debug)] +struct UiFile { + mime: mime::Mime, + path: PathBuf, } -impl Service { - pub fn new(db: Arc, dir: Arc) -> Self { - Service{db: db, dir: dir} - } +struct ServiceInner { + db: Arc, + dir: Arc, + ui_files: HashMap, + pool: futures_cpupool::CpuPool, +} +impl ServiceInner { fn not_found(&self) -> Result, Error> { let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(&b"not found"[..])))); Ok(Response::new() @@ -242,172 +183,46 @@ impl Service { .with_body(body)) } - fn list_cameras(&self, req: &Request) -> Result, Error> { - let json = is_json(req); - let buf = { - let db = self.db.lock(); - if json { - serde_json::to_vec(&json::ListCameras{cameras: db.cameras_by_id()})? - } else { - self.list_cameras_html(db)? - } - }; - let len = buf.len(); - let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(buf)))); - Ok(Response::new() - .with_header(header::ContentType(if json { mime::APPLICATION_JSON } - else { mime::TEXT_HTML })) - .with_header(header::ContentLength(len as u64)) - .with_body(body)) - } - - fn list_cameras_html(&self, db: MutexGuard) -> Result, Error> { - let mut buf = Vec::new(); - buf.extend_from_slice(b"\ - \n\ - \n\ - \n\ - Camera list\n\ - \n\ - \n\ - \n\ - \n\ - \n"); - for row in db.cameras_by_id().values() { - write!(&mut buf, "\ - \n\ - \n\ - \n\ - \n\ - \n\ - \n\ - \n", - row.uuid, HtmlEscaped(&row.short_name), HtmlEscaped(&row.description), - Humanized(row.sample_file_bytes), Humanized(row.retain_bytes), - 100. * row.sample_file_bytes as f32 / row.retain_bytes as f32, - row.uuid, HumanizedTimestamp(row.range.as_ref().map(|r| r.start)), - HumanizedTimestamp(row.range.as_ref().map(|r| r.end)), - row.duration)?; - } - Ok(buf) - } - - fn camera(&self, uuid: Uuid, query: Option<&str>, req: &Request) - -> Result, Error> { - let json = is_json(req); - let buf = { - let db = self.db.lock(); - if json { - let camera = db.get_camera(uuid) - .ok_or_else(|| Error::new("no such camera".to_owned()))?; - serde_json::to_vec(&json::Camera::new(camera, true))? - } else { - self.camera_html(db, query, uuid)? - } - }; - let len = buf.len(); - let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(buf)))); - Ok(Response::new() - .with_header(header::ContentType(if json { mime::APPLICATION_JSON } - else { mime::TEXT_HTML })) - .with_header(header::ContentLength(len as u64)) - .with_body(body)) - } - - fn camera_html(&self, db: MutexGuard, query: Option<&str>, - uuid: Uuid) -> Result, Error> { - let (r, split, trim) = { - let mut time = recording::Time(i64::min_value()) .. recording::Time(i64::max_value()); - let mut split = recording::Duration(60 * 60 * recording::TIME_UNITS_PER_SEC); - let mut trim = false; - if let Some(q) = query { - for (key, value) in form_urlencoded::parse(q.as_bytes()) { - let (key, value) = (key.borrow(), value.borrow()); - match key { - "startTime" => time.start = recording::Time::parse(value)?, - "endTime" => time.end = recording::Time::parse(value)?, - "split" => split = recording::Duration(i64::from_str(value)?), - "trim" if value == "true" => trim = true, - _ => {}, - } + fn top_level(&self, query: Option<&str>) -> Result, Error> { + let mut days = false; + if let Some(q) = query { + for (key, value) in form_urlencoded::parse(q.as_bytes()) { + let (key, value) : (_, &str) = (key.borrow(), value.borrow()); + match key { + "days" => days = value == "true", + _ => {}, }; } - (time, split, trim) + } + + let buf = { + let db = self.db.lock(); + serde_json::to_vec(&json::ListCameras{cameras: (db.cameras_by_id(), days)})? }; - let camera = db.get_camera(uuid) - .ok_or_else(|| Error::new("no such camera".to_owned()))?; - let mut buf = Vec::new(); - write!(&mut buf, "\ - \n\ - \n\ - \n\ - {0} recordings\n\ - \n\ - \n\ - \n\ - \n\ -

{0}

\n\ -

{1}

\n\ -
{}
description{}
space{:b}B / {:b}B ({:.1}%)
uuid{}
oldest recording{}
newest recording{}
total duration{}
\n\ - \ - \ - \n", - HtmlEscaped(&camera.short_name), HtmlEscaped(&camera.description))?; - - let mut rows = Vec::new(); - db.list_aggregated_recordings(camera.id, r.clone(), split, |row| { - rows.push(row.clone()); - Ok(()) - })?; - - // Display newest recording first. - rows.sort_by(|r1, r2| r2.ids.start.cmp(&r1.ids.start)); - - for row in &rows { - let seconds = (row.time.end.0 - row.time.start.0) / recording::TIME_UNITS_PER_SEC; - let url = { - let mut url = String::with_capacity(64); - use std::fmt::Write; - write!(&mut url, "view.mp4?s={}", row.ids.start)?; - if row.ids.end != row.ids.start + 1 { - write!(&mut url, "-{}", row.ids.end - 1)?; - } - if trim { - let rel_start = if row.time.start < r.start { Some(r.start - row.time.start) } - else { None }; - let rel_end = if row.time.end > r.end { Some(r.end - row.time.start) } - else { None }; - if rel_start.is_some() || rel_end.is_some() { - url.push('.'); - if let Some(s) = rel_start { write!(&mut url, "{}", s.0)?; } - url.push('-'); - if let Some(e) = rel_end { write!(&mut url, "{}", e.0)?; } - } - } - url - }; - let start = if trim && row.time.start < r.start { r.start } else { row.time.start }; - let end = if trim && row.time.end > r.end { r.end } else { row.time.end }; - write!(&mut buf, "\ - \ - \n", - url, HumanizedTimestamp(Some(start)), HumanizedTimestamp(Some(end)), - row.video_sample_entry.width, row.video_sample_entry.height, - if seconds == 0 { 0. } else { row.video_samples as f32 / seconds as f32 }, - Humanized(row.sample_file_bytes), - Humanized(if seconds == 0 { 0 } else { row.sample_file_bytes * 8 / seconds }))?; - }; - buf.extend_from_slice(b"
startendresolutionfpssizebitrate
{}{}{}x{}{:.0}{:b}B{}bps
\n\n"); - Ok(buf) + let len = buf.len(); + let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(buf)))); + Ok(Response::new() + .with_header(header::ContentType(mime::APPLICATION_JSON)) + .with_header(header::ContentLength(len as u64)) + .with_body(body)) } - fn camera_recordings(&self, uuid: Uuid, query: Option<&str>, req: &Request) + fn camera(&self, uuid: Uuid) -> Result, Error> { + let buf = { + let db = self.db.lock(); + let camera = db.get_camera(uuid) + .ok_or_else(|| Error::new("no such camera".to_owned()))?; + serde_json::to_vec(&json::Camera::new(camera, true))? + }; + let len = buf.len(); + let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(buf)))); + Ok(Response::new() + .with_header(header::ContentType(mime::APPLICATION_JSON)) + .with_header(header::ContentLength(len as u64)) + .with_body(body)) + } + + fn camera_recordings(&self, uuid: Uuid, query: Option<&str>) -> Result, Error> { let (r, split) = { let mut time = recording::Time(i64::min_value()) .. recording::Time(i64::max_value()); @@ -425,13 +240,6 @@ impl Service { } (time, split) }; - if !is_json(req) { - let body: slices::Body = Box::new(stream::once( - Ok(ARefs::new(&b"only available for JSON requests"[..])))); - return Ok(Response::new() - .with_status(hyper::StatusCode::NotAcceptable) - .with_body(body)); - } let mut out = json::ListRecordings{recordings: Vec::new()}; { let db = self.db.lock(); @@ -566,6 +374,75 @@ impl Service { let mp4 = builder.build(self.db.clone(), self.dir.clone())?; Ok(http_entity::serve(mp4, req)) } + + fn static_file(&self, req: &Request) -> Result, Error> { + let s = match self.ui_files.get(req.uri().path()) { + None => { return self.not_found() }, + Some(s) => s, + }; + let f = fs::File::open(&s.path)?; + let e = http_file::ChunkedReadFile::new(f, Some(self.pool.clone()), s.mime.clone())?; + Ok(http_entity::serve(e, &req)) + } +} + +#[derive(Clone)] +pub struct Service(Arc); + +impl Service { + pub fn new(db: Arc, dir: Arc, ui_dir: Option<&str>) + -> Result { + let mut ui_files = HashMap::new(); + if let Some(d) = ui_dir { + Service::fill_ui_files(d, &mut ui_files); + } + debug!("UI files: {:#?}", ui_files); + Ok(Service(Arc::new(ServiceInner { + db, + dir, + ui_files, + pool: futures_cpupool::Builder::new().pool_size(1).name_prefix("static").create(), + }))) + } + + fn fill_ui_files(dir: &str, files: &mut HashMap) { + let r = match fs::read_dir(dir) { + Ok(r) => r, + Err(e) => { + warn!("Unable to search --ui-dir={}; will serve no static files. Error was: {}", + dir, e); + return; + } + }; + for e in r { + let e = match e { + Ok(e) => e, + Err(e) => { + warn!("Error searching UI directory; may be missing files. Error was: {}", e); + continue; + }, + }; + let (p, mime) = match e.file_name().to_str() { + Some(n) if n == "index.html" => ("/".to_owned(), mime::TEXT_HTML), + Some(n) if n.ends_with(".js") => (format!("/{}", n), mime::TEXT_JAVASCRIPT), + Some(n) if n.ends_with(".html") => (format!("/{}", n), mime::TEXT_HTML), + Some(n) if n.ends_with(".png") => (format!("/{}", n), mime::IMAGE_PNG), + Some(n) => { + warn!("UI directory file {:?} has unknown extension; skipping", n); + continue; + }, + None => { + warn!("UI directory file {:?} is not a valid UTF-8 string; skipping", + e.file_name()); + continue; + }, + }; + files.insert(p, UiFile { + mime, + path: e.path(), + }); + } + } } impl server::Service for Service { @@ -577,17 +454,18 @@ impl server::Service for Service { fn call(&self, req: Request) -> Self::Future { debug!("request on: {}", req.uri()); let res = match decode_path(req.uri().path()) { - Path::InitSegment(sha1) => self.init_segment(sha1, &req), - Path::CamerasList => self.list_cameras(&req), - Path::Camera(uuid) => self.camera(uuid, req.uri().query(), &req), - Path::CameraRecordings(uuid) => self.camera_recordings(uuid, req.uri().query(), &req), + Path::InitSegment(sha1) => self.0.init_segment(sha1, &req), + Path::TopLevel => self.0.top_level(req.uri().query()), + Path::Camera(uuid) => self.0.camera(uuid), + Path::CameraRecordings(uuid) => self.0.camera_recordings(uuid, req.uri().query()), Path::CameraViewMp4(uuid) => { - self.camera_view_mp4(uuid, mp4::Type::Normal, req.uri().query(), &req) + self.0.camera_view_mp4(uuid, mp4::Type::Normal, req.uri().query(), &req) }, Path::CameraViewMp4Segment(uuid) => { - self.camera_view_mp4(uuid, mp4::Type::MediaSegment, req.uri().query(), &req) + self.0.camera_view_mp4(uuid, mp4::Type::MediaSegment, req.uri().query(), &req) }, - Path::NotFound => self.not_found(), + Path::NotFound => self.0.not_found(), + Path::Static => self.0.static_file(&req), }; future::result(res.map_err(|e| { error!("error: {}", e); @@ -598,27 +476,9 @@ impl server::Service for Service { #[cfg(test)] mod tests { - use super::{HtmlEscaped, Humanized, Segments}; + use super::Segments; use testutil; - #[test] - fn test_humanize() { - testutil::init(); - assert_eq!("1.0 B", format!("{:b}B", Humanized(1))); - assert_eq!("1.0 EiB", format!("{:b}B", Humanized(1i64 << 60))); - assert_eq!("1.5 EiB", format!("{:b}B", Humanized((1i64 << 60) + (1i64 << 59)))); - assert_eq!("8.0 EiB", format!("{:b}B", Humanized(i64::max_value()))); - assert_eq!("1.0 Mbps", format!("{}bps", Humanized(1_000_000))); - } - - #[test] - fn test_html_escaped() { - testutil::init(); - assert_eq!("", format!("{}", HtmlEscaped(""))); - assert_eq!("no special chars", format!("{}", HtmlEscaped("no special chars"))); - assert_eq!("a <tag> & text", format!("{}", HtmlEscaped("a & text"))); - } - #[test] fn test_segments() { testutil::init(); @@ -662,8 +522,9 @@ mod bench { ::std::thread::spawn(move || { let addr = "127.0.0.1:0".parse().unwrap(); let (db, dir) = (db.db.clone(), db.dir.clone()); + let service = super::Service::new(db.clone(), dir.clone(), None); let server = hyper::server::Http::new() - .bind(&addr, move || Ok(super::Service::new(db.clone(), dir.clone()))) + .bind(&addr, move || Ok(service.clone())) .unwrap(); tx.send(server.local_addr().unwrap()).unwrap(); server.run().unwrap(); @@ -678,10 +539,10 @@ mod bench { } #[bench] - fn serve_camera_html(b: &mut Bencher) { + fn serve_camera_recordings(b: &mut Bencher) { testutil::init(); let server = &*SERVER; - let url = reqwest::Url::parse(&format!("{}/cameras/{}/", server.base_url, + let url = reqwest::Url::parse(&format!("{}/cameras/{}/recordings", server.base_url, *testutil::TEST_CAMERA_UUID)).unwrap(); let mut buf = Vec::new(); let client = reqwest::Client::new().unwrap(); diff --git a/ui-src/index.html b/ui-src/index.html new file mode 100644 index 0000000..c3b4590 --- /dev/null +++ b/ui-src/index.html @@ -0,0 +1,81 @@ + + + + + moonfire ui + + + + + +
+ + + diff --git a/ui-src/index.js b/ui-src/index.js new file mode 100644 index 0000000..4f6359e --- /dev/null +++ b/ui-src/index.js @@ -0,0 +1,386 @@ +// vim: set et sw=2: + +// TODO: test abort. +// TODO: add error bar on fetch failure. +// TODO: style: no globals? string literals? line length? fn comments? +// TODO: live updating. + +import 'jquery-ui/themes/base/button.css'; +import 'jquery-ui/themes/base/core.css'; +import 'jquery-ui/themes/base/datepicker.css'; +import 'jquery-ui/themes/base/dialog.css'; +import 'jquery-ui/themes/base/resizable.css'; +import 'jquery-ui/themes/base/theme.css'; +import 'jquery-ui/themes/base/tooltip.css'; + +import $ from 'jquery'; +import 'jquery-ui/ui/widgets/datepicker'; +import 'jquery-ui/ui/widgets/dialog'; +import 'jquery-ui/ui/widgets/tooltip'; +import moment from 'moment-timezone'; + +const apiUrl = '/api/'; + +// IANA timezone name. +let zone = null; + +// A dict describing the currently selected range. +let selectedRange = { + startDateStr: null, // null or YYYY-MM-DD + startTimeStr: '', // empty or HH:mm[:ss[:FFFFF]][+-HHmm] + startTime90k: null, // null or 90k units since epoch + endDateStr: null, // null or YYYY-MM-DD + endTimeStr: '', // empty or HH:mm[:ss[:FFFFF]][+-HHmm] + endTime90k: null, // null or 90k units since epoch + singleDateStr: null, // if startDateStr===endDateStr, that value, otherwise null +}; + +// Cameras is a dictionary as retrieved from apiUrl + some extra props: +// * "enabled" is a boolean indicating if the camera should be displayed and +// if it should be used to constrain the datepickers. +// * "recordingsUrl" is null or the currently fetched/fetching .../recordings url. +// * "recordingsRange" is a null or a dict (in the same format as +// selectedRange) describing what is fetching/fetched. +// * "recordingsData" is null or the data fetched from "recordingsUrl". +// * "recordingsReq" is null or a jQuery ajax object of an active .../recordings +// request if currently fetching. +let cameras = null; + +function req(url) { + return $.ajax(url, { + dataType: 'json', + headers: {'Accept': 'application/json'}, + }); +} + +// Produces a human-readable but unambiguous format of the given timestamp: +// ISO-8601 plus an extra colon component after the seconds indicating the +// fractional time in 90,000ths of a second. +function formatTime(ts90k) { + const m = moment.tz(ts90k / 90, zone); + const frac = ts90k % 90000; + return m.format('YYYY-MM-DDTHH:mm:ss:' + String(100000 + frac).substr(1) + 'Z'); +} + +function onSelectVideo(camera, range, recording) { + let url = apiUrl + 'cameras/' + camera.uuid + '/view.mp4?s=' + recording.startId; + if (recording.endId !== undefined) { + url += '-' + recording.endId; + } + const trim = $("#trim").prop("checked"); + let rel = ''; + let startTime90k = recording.startTime90k; + if (trim && recording.startTime90k < range.startTime90k) { + rel += range.startTime90k - recording.startTime90k; + startTime90k = range.startTime90k; + } + rel += '-'; + let endTime90k = recording.endTime90k; + if (trim && recording.endTime90k > range.endTime90k) { + rel += range.endTime90k - recording.startTime90k; + endTime90k = range.endTime90k; + } + if (rel !== '-') { + url += '.' + rel; + } + if ($("#ts").prop("checked")) { + url += '&ts=true'; + } + console.log('Displaying video: ', url); + let video = $('