add a basic Javascript UI

The Javascript is pretty amateurish I'm sure but at least it's something to
iterate from. It's already much more pleasant for browsing through videos in
several ways:

* more responsive to load only a day at a time rather than 90+ days
* much easier to see the same time segment on several cameras
* more pleasant to have the videos load as a popup rather than a link
  that blows away your position in an enormous list
* exposes the fancier .mp4 generation options: splitting at lengths
  other than the default, trimming to an arbitrary start and end time,
  including a subtitle track with timestamps.

There's a slight regression in functionality: I didn't match the former
top-level page which showed how much camera used of its disk allocation and
the total duration of video. This is exposed in the JSON API, so it shouldn't
be too hard to add back.
This commit is contained in:
Scott Lamb 2017-10-21 21:54:27 -07:00
parent 6eda26a9cc
commit 315f3594c2
19 changed files with 4411 additions and 354 deletions

4
.gitignore vendored
View File

@ -2,5 +2,7 @@ cameras.sql
.project .project
.settings .settings
*.swp *.swp
target node_modules
prep.config prep.config
target
ui-dist

20
Cargo.lock generated
View File

@ -7,7 +7,9 @@ dependencies = [
"docopt 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", "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)", "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)", "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)", "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)", "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)", "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]] [[package]]
name = "http-entity" name = "http-entity"
version = "0.0.1" version = "0.0.1"
source = "git+https://github.com/scottlamb/http-entity#d8a51ae8602a1b80dc757fd4a404fc878f599ca7"
dependencies = [ dependencies = [
"futures 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)", "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)", "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)", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.2.3" 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 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 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 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)" = "<none>"
"checksum httparse 1.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "af2f2dd97457e8fb1ae7c5a420db346af389926e36f43768b96f101546b04a07" "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)" = "<none>" "checksum hyper 0.11.2 (git+https://github.com/scottlamb/hyper?branch=moonfire-on-0.11.x)" = "<none>"
"checksum hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)" = "641abc3e3fcf0de41165595f801376e01106bca1fd876dda937730e477ca004c" "checksum hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)" = "641abc3e3fcf0de41165595f801376e01106bca1fd876dda937730e477ca004c"

View File

@ -17,8 +17,12 @@ bundled = ["rusqlite/bundled"]
byteorder = "1.0" byteorder = "1.0"
docopt = "0.8" docopt = "0.8"
futures = "0.1" futures = "0.1"
futures-cpupool = "0.1"
fnv = "1.0" 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" hyper = "0.11.2"
lazy_static = "0.2" lazy_static = "0.2"
libc = "0.2" libc = "0.2"

View File

@ -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 2](https://www.raspberrypi.org/products/raspberry-pi-2-model-b/), using
less than 10% of the machine's total CPU. less than 10% of the machine's total CPU.
So far, the web interface is basic: just a table with links to one-hour So far, the web interface is basic: a filterable list of video segments,
segments of video. Although the backend supports generating `.mp4` files for with support for trimming them to arbitrary time ranges. No scrub bar yet.
arbitrary time ranges, you have to construct URLs by hand. There's also no There's also no support for motion detection, no authentication, and no config
support for motion detection, no authentication, and no config UI. UI.
[![screenshot](screenshot-smaller.png | 704x474)](screenshot.png)
This is version 0.1, the initial release. Until version 1.0, there will be no 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 compatibility guarantees: configuration and storage formats may change from

View File

@ -22,31 +22,57 @@ HTML rather than JSON.
TODO(slamb): authentication. TODO(slamb): authentication.
### `/cameras/` ### `/api/`
A `GET` request on this URL returns basic information about all cameras. The A `GET` request on this URL returns basic information about the server,
`application/json` response will have a top-level `cameras` with a list of including all cameras. Valid request parameters:
attributes about each camera:
* `uuid`: in text format * `days`: a boolean indicating if the days parameter described below
* `shortName`: a short name (typically one or two words) should be included.
* `description`: a longer description (typically a phrase or paragraph)
* `retainBytes`: the configured total number of bytes of completed Example request URI:
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. /api/?days=true
* `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. The `application/json` response will have a dict as follows:
This is no greater than `maxEndTime90k - maxStartTime90k`; it
will be lesser if there are gaps in the recorded data. * `timeZoneName`: the name of the IANA time zone the server is using
* `totalSampleFileBytes`: the total number of bytes of sample data (the to divide recordings into days as described further below.
`mdat` portion of a `.mp4` file). * `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: Example response:
```json ```json
{ {
"timeZoneName": "America/Los_Angeles",
"cameras": [ "cameras": [
{ {
"uuid": "fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe", "uuid": "fd20f7a2-9d69-4cb3-94ed-d51a20c3edfe",
@ -57,31 +83,27 @@ Example response:
"maxEndTime90k": 130985466591817, "maxEndTime90k": 130985466591817,
"totalDuration90k": 96736169725, "totalDuration90k": 96736169725,
"totalSampleFileBytes": 446774393937, "totalSampleFileBytes": 446774393937,
"days": {
"2016-05-01": {
"endTime90k": 131595516000000,
"startTime90k": 131587740000000,
"totalDuration90k": 52617609
},
"2016-05-02": {
"endTime90k": 131603292000000,
"startTime90k": 131595516000000,
"totalDuration90k": 20946022
}
},
}, },
... ...
], ],
} }
``` ```
### `/cameras/<uuid>/` ### `/api/cameras/<uuid>/`
A GET returns information for the camera with the given URL. The information 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: Example response:
@ -99,17 +121,17 @@ Example response:
"totalDuration90k": 20946022 "totalDuration90k": 20946022
} }
}, },
"description":"", "description": "",
"maxEndTime90k": 131598273666690, "maxEndTime90k": 131598273666690,
"minStartTime90k": 131590386129355, "minStartTime90k": 131590386129355,
"retainBytes": 104857600, "retainBytes": 104857600,
"shortName": "driveway", "shortName": "driveway",
"totalDuration90k": 73563631, "totalDuration90k": 73563631,
"totalSampleFileBytes": 98901406, "totalSampleFileBytes": 98901406
} }
``` ```
### `/camera/<uuid>/recordings` ### `/api/cameras/<uuid>/recordings`
A GET returns information about recordings, in descending order. 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): 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 ?startTime90k=130888729442361
&endTime90k=130985466591817 &endTime90k=130985466591817
``` ```
@ -182,7 +204,7 @@ Example response:
} }
``` ```
### `/camera/<uuid>/view.mp4` ### `/api/cameras/<uuid>/view.mp4`
A GET returns a `.mp4` file, with an etag and support for range requests. The 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 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: 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 15 from the given camera, Example request URI to retrieve all of recording ids 15 from the given camera,
with timestamp subtitles: 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 Example request URI to retrieve recording id 1, skipping its first 26
90,000ths of a second: 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 TODO: error behavior on missing segment. It should be a 404, likely with an
`application/json` body describing what portion if any (still) exists. `application/json` body describing what portion if any (still) exists.
### `/camera/<uuid>/view.m4s` ### `/api/cameras/<uuid>/view.m4s`
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions 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 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 than one video sample entry, so a `.m4s` that uses more than one video
sample entry can't be used. sample entry can't be used.
### `/init/<sha1>.mp4` ### `/api/init/<sha1>.mp4`
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions 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 initialization segment][init-segment]. The MIME type will be `video/mp4`, with

View File

@ -17,10 +17,7 @@ from source.
Moonfire NVR is written in the [Rust Programming Moonfire NVR is written in the [Rust Programming
Language](https://www.rust-lang.org/en-US/). In the long term, I expect this 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 will result in a more secure, full-featured, easy-to-install software.
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.
You will need the following C libraries installed: 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 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/). 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 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`. 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: Once prerequisites are installed, Moonfire NVR can be built as follows:
$ yarn build
$ cargo test $ cargo test
$ cargo build --release $ cargo build --release
$ sudo install -m 755 target/release/moonfire-nvr /usr/local/bin $ 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 ## Further configuration

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"author": "Scott Lamb <slamb@slamb.org>",
"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"
}
}

27
prep.sh
View File

@ -76,6 +76,10 @@ fi
# #
#SERVICE_BIN= #SERVICE_BIN=
# Resource files location
# Default: "/usr/local/lib/moonfire-nvr"
#LIB_DIR=/usr/local/lib/moonfire-nvr
# Service name # Service name
# Default: "moonfire-nvr" # Default: "moonfire-nvr"
# #
@ -111,6 +115,7 @@ SAMPLES_DIR="${SAMPLES_DIR:-$NVR_HOME/$SAMPLES_DIR_NAME}"
SERVICE_NAME="${SERVICE_NAME:-moonfire-nvr}" SERVICE_NAME="${SERVICE_NAME:-moonfire-nvr}"
SERVICE_DESC="${SERVICE_DESC:-Moonfire NVR}" SERVICE_DESC="${SERVICE_DESC:-Moonfire NVR}"
SERVICE_BIN="${SERVICE_BIN:-/usr/local/bin/moonfire-nvr}" SERVICE_BIN="${SERVICE_BIN:-/usr/local/bin/moonfire-nvr}"
LIB_DIR="${UI_DIR:-/usr/local/lib/moonfire-nvr}"
# Process command line options # Process command line options
# #
@ -150,7 +155,7 @@ if [ ! -x "${SERVICE_BIN}" ]; then
echo "Binary not installed, building..."; echo echo "Binary not installed, building..."; echo
if ! cargo --version; then if ! cargo --version; then
echo "cargo not installed/working." echo "cargo not installed/working."
echo "Install a nightly Rust (see http://rustup.us) first." echo "Install Rust (see http://rustup.us) first."
echo echo
exit 1 exit 1
fi fi
@ -161,7 +166,7 @@ if [ ! -x "${SERVICE_BIN}" ]; then
exit 1 exit 1
fi fi
if ! cargo build --release; then if ! cargo build --release; then
echo "Build failed." echo "Server build failed."
echo "RUST_TEST_THREADS=1 cargo build --release --verbose" echo "RUST_TEST_THREADS=1 cargo build --release --verbose"
echo echo
exit 1 exit 1
@ -174,6 +179,23 @@ if [ ! -x "${SERVICE_BIN}" ]; then
exit 1 exit 1
fi fi
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 # Create user and groups
# #
@ -224,6 +246,7 @@ After=network-online.target
ExecStart=${SERVICE_BIN} run \\ ExecStart=${SERVICE_BIN} run \\
--sample-file-dir=${SAMPLES_PATH} \\ --sample-file-dir=${SAMPLES_PATH} \\
--db-dir=${DB_DIR} \\ --db-dir=${DB_DIR} \\
--ui-dir=${LIB_DIR}/ui \\
--http-addr=0.0.0.0:${NVR_PORT} --http-addr=0.0.0.0:${NVR_PORT}
Environment=TZ=:/etc/localtime Environment=TZ=:/etc/localtime
Environment=MOONFIRE_FORMAT=google-systemd Environment=MOONFIRE_FORMAT=google-systemd

BIN
screenshot-small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

View File

@ -53,6 +53,8 @@ Options:
--sample-file-dir=DIR Set the directory holding video data. --sample-file-dir=DIR Set the directory holding video data.
This is typically on a hard drive. This is typically on a hard drive.
[default: /var/lib/moonfire-nvr/sample] [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. --http-addr=ADDR Set the bind address for the unencrypted HTTP server.
[default: 0.0.0.0:8080] [default: 0.0.0.0:8080]
--read-only Forces read-only mode / disables recording. --read-only Forces read-only mode / disables recording.
@ -63,6 +65,7 @@ struct Args {
flag_db_dir: String, flag_db_dir: String,
flag_sample_file_dir: String, flag_sample_file_dir: String,
flag_http_addr: String, flag_http_addr: String,
flag_ui_dir: String,
flag_read_only: bool, 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(); let dir = dir::SampleFileDir::new(&args.flag_sample_file_dir, db.clone()).unwrap();
info!("Database is loaded."); info!("Database is loaded.");
let s = web::Service::new(db.clone(), dir.clone(), Some(&args.flag_ui_dir))?;
// Start a streamer for each camera. // Start a streamer for each camera.
let shutdown_streamers = Arc::new(AtomicBool::new(false)); let shutdown_streamers = Arc::new(AtomicBool::new(false));
let mut streamers = Vec::new(); let mut streamers = Vec::new();
@ -113,7 +118,7 @@ pub fn run() -> Result<(), Error> {
// Start the web interface. // Start the web interface.
let addr = args.flag_http_addr.parse().unwrap(); let addr = args.flag_http_addr.parse().unwrap();
let server = ::hyper::server::Http::new() let server = ::hyper::server::Http::new()
.bind(&addr, move || Ok(web::Service::new(db.clone(), dir.clone()))) .bind(&addr, move || Ok(s.clone()))
.unwrap(); .unwrap();
let shutdown = setup_shutdown_future(&server.handle()); let shutdown = setup_shutdown_future(&server.handle());

View File

@ -35,9 +35,10 @@ use uuid::Uuid;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ListCameras<'a> { 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")] #[serde(serialize_with = "ListCameras::serialize_cameras")]
pub cameras: &'a BTreeMap<i32, db::Camera>, pub cameras: (&'a BTreeMap<i32, db::Camera>, bool),
} }
/// JSON serialization wrapper for a single camera when processing `/cameras/` and /// JSON serialization wrapper for a single camera when processing `/cameras/` and
@ -106,12 +107,12 @@ struct CameraDayValue {
impl<'a> ListCameras<'a> { impl<'a> ListCameras<'a> {
/// Serializes cameras as a list (rather than a map), wrapping each camera in the /// Serializes cameras as a list (rather than a map), wrapping each camera in the
/// `ListCamerasCamera` type to tweak the data returned. /// `ListCamerasCamera` type to tweak the data returned.
fn serialize_cameras<S>(cameras: &BTreeMap<i32, db::Camera>, fn serialize_cameras<S>(cameras: &(&BTreeMap<i32, db::Camera>, bool),
serializer: S) -> Result<S::Ok, S::Error> serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer { where S: Serializer {
let mut seq = serializer.serialize_seq(Some(cameras.len()))?; let mut seq = serializer.serialize_seq(Some(cameras.0.len()))?;
for c in cameras.values() { for c in cameras.0.values() {
seq.serialize_element(&Camera::new(c, false))?; seq.serialize_element(&Camera::new(c, cameras.1))?;
} }
seq.end() seq.end()
} }

View File

@ -34,8 +34,10 @@ extern crate byteorder;
extern crate core; extern crate core;
extern crate docopt; extern crate docopt;
extern crate futures; extern crate futures;
extern crate futures_cpupool;
extern crate fnv; extern crate fnv;
extern crate http_entity; extern crate http_entity;
extern crate http_file;
extern crate hyper; extern crate hyper;
#[macro_use] extern crate lazy_static; #[macro_use] extern crate lazy_static;
extern crate libc; extern crate libc;

View File

@ -36,31 +36,29 @@ use db;
use dir::SampleFileDir; use dir::SampleFileDir;
use error::Error; use error::Error;
use futures::{future, stream}; use futures::{future, stream};
use futures_cpupool;
use json; use json;
use http_entity; use http_entity;
use http_file;
use hyper::header; use hyper::header;
use hyper::server::{self, Request, Response}; use hyper::server::{self, Request, Response};
use mime; use mime;
use mp4; use mp4;
use parking_lot::MutexGuard;
use recording; use recording;
use reffers::ARefs; use reffers::ARefs;
use regex::Regex; use regex::Regex;
use serde_json; use serde_json;
use slices; use slices;
use std::collections::HashMap;
use std::cmp; use std::cmp;
use std::fmt; use std::fs;
use std::io::Write;
use std::ops::Range; use std::ops::Range;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use strutil; use strutil;
use time;
use url::form_urlencoded; use url::form_urlencoded;
use uuid::Uuid; 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! { lazy_static! {
/// Regex used to parse the `s` query parameter to `view.mp4`. /// Regex used to parse the `s` query parameter to `view.mp4`.
/// As described in `design/api.md`, this is of the form /// As described in `design/api.md`, this is of the form
@ -69,18 +67,23 @@ lazy_static! {
} }
enum Path { enum Path {
CamerasList, // "/" or "/cameras/" TopLevel, // "/api/"
InitSegment([u8; 20]), // "/init/<sha1>.mp4" InitSegment([u8; 20]), // "/api/init/<sha1>.mp4"
Camera(Uuid), // "/cameras/<uuid>/" Camera(Uuid), // "/api/cameras/<uuid>/"
CameraRecordings(Uuid), // "/cameras/<uuid>/recordings" CameraRecordings(Uuid), // "/api/cameras/<uuid>/recordings"
CameraViewMp4(Uuid), // "/cameras/<uuid>/view.mp4" CameraViewMp4(Uuid), // "/api/cameras/<uuid>/view.mp4"
CameraViewMp4Segment(Uuid), // "/cameras/<uuid>/view.m4s" CameraViewMp4Segment(Uuid), // "/api/cameras/<uuid>/view.m4s"
Static, // "<other path>"
NotFound, NotFound,
} }
fn decode_path(path: &str) -> Path { fn decode_path(path: &str) -> Path {
if !path.starts_with("/api/") {
return Path::Static;
}
let path = &path["/api".len()..];
if path == "/" { if path == "/" {
return Path::CamerasList; return Path::TopLevel;
} }
if path.starts_with("/init/") { if path.starts_with("/init/") {
if path.len() != 50 || !path.ends_with(".mp4") { if path.len() != 50 || !path.ends_with(".mp4") {
@ -95,9 +98,6 @@ fn decode_path(path: &str) -> Path {
return Path::NotFound; return Path::NotFound;
} }
let path = &path["/cameras/".len()..]; let path = &path["/cameras/".len()..];
if path == "" {
return Path::CamerasList;
}
let slash = match path.find('/') { let slash = match path.find('/') {
None => { return Path::NotFound; }, None => { return Path::NotFound; },
Some(s) => s, 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::<header::Accept>() {
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 == "<" { "&lt;" } else { "&amp;" })?;
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<recording::Time>);
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)] #[derive(Debug, Eq, PartialEq)]
struct Segments { struct Segments {
ids: Range<i32>, ids: Range<i32>,
@ -224,16 +158,23 @@ impl Segments {
} }
} }
pub struct Service { /// A user interface file (.html, .js, etc).
db: Arc<db::Database>, /// The list of files is loaded into the server at startup; this makes path canonicalization easy.
dir: Arc<SampleFileDir>, /// 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 { struct ServiceInner {
pub fn new(db: Arc<db::Database>, dir: Arc<SampleFileDir>) -> Self { db: Arc<db::Database>,
Service{db: db, dir: dir} dir: Arc<SampleFileDir>,
} ui_files: HashMap<String, UiFile>,
pool: futures_cpupool::CpuPool,
}
impl ServiceInner {
fn not_found(&self) -> Result<Response<slices::Body>, Error> { fn not_found(&self) -> Result<Response<slices::Body>, Error> {
let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(&b"not found"[..])))); let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(&b"not found"[..]))));
Ok(Response::new() Ok(Response::new()
@ -242,172 +183,46 @@ impl Service {
.with_body(body)) .with_body(body))
} }
fn list_cameras(&self, req: &Request) -> Result<Response<slices::Body>, Error> { fn top_level(&self, query: Option<&str>) -> Result<Response<slices::Body>, Error> {
let json = is_json(req); let mut days = false;
let buf = { if let Some(q) = query {
let db = self.db.lock(); for (key, value) in form_urlencoded::parse(q.as_bytes()) {
if json { let (key, value) : (_, &str) = (key.borrow(), value.borrow());
serde_json::to_vec(&json::ListCameras{cameras: db.cameras_by_id()})? match key {
} else { "days" => days = value == "true",
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<db::LockedDatabase>) -> Result<Vec<u8>, Error> {
let mut buf = Vec::new();
buf.extend_from_slice(b"\
<!DOCTYPE html>\n\
<html>\n\
<head>\n\
<title>Camera list</title>\n\
<meta http-equiv=\"Content-Language\" content=\"en\">\n\
<style type=\"text/css\">\n\
.header { background-color: #ddd; }\n\
td { padding-right: 3em; }\n\
</style>\n\
</head>\n\
<body>\n\
<table>\n");
for row in db.cameras_by_id().values() {
write!(&mut buf, "\
<tr class=header><td colspan=2><a href=\"/cameras/{}/\">{}</a></td></tr>\n\
<tr><td>description</td><td>{}</td></tr>\n\
<tr><td>space</td><td>{:b}B / {:b}B ({:.1}%)</td></tr>\n\
<tr><td>uuid</td><td>{}</td></tr>\n\
<tr><td>oldest recording</td><td>{}</td></tr>\n\
<tr><td>newest recording</td><td>{}</td></tr>\n\
<tr><td>total duration</td><td>{}</td></tr>\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<Response<slices::Body>, 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<db::LockedDatabase>, query: Option<&str>,
uuid: Uuid) -> Result<Vec<u8>, 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,
_ => {},
}
}; };
} }
(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) let len = buf.len();
.ok_or_else(|| Error::new("no such camera".to_owned()))?; let body: slices::Body = Box::new(stream::once(Ok(ARefs::new(buf))));
let mut buf = Vec::new(); Ok(Response::new()
write!(&mut buf, "\ .with_header(header::ContentType(mime::APPLICATION_JSON))
<!DOCTYPE html>\n\ .with_header(header::ContentLength(len as u64))
<html>\n\ .with_body(body))
<head>\n\
<title>{0} recordings</title>\n\
<meta http-equiv=\"Content-Language\" content=\"en\">\n\
<style type=\"text/css\">\n\
tr:not(:first-child):hover {{ background-color: #ddd; }}\n\
th, td {{ padding: 0.5ex 1.5em; text-align: right; }}\n\
</style>\n\
</head>\n\
<body>\n\
<h1>{0}</h1>\n\
<p>{1}</p>\n\
<table>\n\
<tr><th>start</th><th>end</th><th>resolution</th>\
<th>fps</th><th>size</th><th>bitrate</th>\
</tr>\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, "\
<tr><td><a href=\"{}\">{}</a></td>\
<td>{}</td><td>{}x{}</td><td>{:.0}</td><td>{:b}B</td><td>{}bps</td></tr>\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"</table>\n</html>\n");
Ok(buf)
} }
fn camera_recordings(&self, uuid: Uuid, query: Option<&str>, req: &Request) fn camera(&self, uuid: Uuid) -> Result<Response<slices::Body>, 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<Response<slices::Body>, Error> { -> Result<Response<slices::Body>, Error> {
let (r, split) = { let (r, split) = {
let mut time = recording::Time(i64::min_value()) .. recording::Time(i64::max_value()); let mut time = recording::Time(i64::min_value()) .. recording::Time(i64::max_value());
@ -425,13 +240,6 @@ impl Service {
} }
(time, split) (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 mut out = json::ListRecordings{recordings: Vec::new()};
{ {
let db = self.db.lock(); let db = self.db.lock();
@ -566,6 +374,75 @@ impl Service {
let mp4 = builder.build(self.db.clone(), self.dir.clone())?; let mp4 = builder.build(self.db.clone(), self.dir.clone())?;
Ok(http_entity::serve(mp4, req)) Ok(http_entity::serve(mp4, req))
} }
fn static_file(&self, req: &Request) -> Result<Response<slices::Body>, 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<ServiceInner>);
impl Service {
pub fn new(db: Arc<db::Database>, dir: Arc<SampleFileDir>, ui_dir: Option<&str>)
-> Result<Self, Error> {
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<String, UiFile>) {
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 { impl server::Service for Service {
@ -577,17 +454,18 @@ impl server::Service for Service {
fn call(&self, req: Request) -> Self::Future { fn call(&self, req: Request) -> Self::Future {
debug!("request on: {}", req.uri()); debug!("request on: {}", req.uri());
let res = match decode_path(req.uri().path()) { let res = match decode_path(req.uri().path()) {
Path::InitSegment(sha1) => self.init_segment(sha1, &req), Path::InitSegment(sha1) => self.0.init_segment(sha1, &req),
Path::CamerasList => self.list_cameras(&req), Path::TopLevel => self.0.top_level(req.uri().query()),
Path::Camera(uuid) => self.camera(uuid, req.uri().query(), &req), Path::Camera(uuid) => self.0.camera(uuid),
Path::CameraRecordings(uuid) => self.camera_recordings(uuid, req.uri().query(), &req), Path::CameraRecordings(uuid) => self.0.camera_recordings(uuid, req.uri().query()),
Path::CameraViewMp4(uuid) => { 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) => { 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| { future::result(res.map_err(|e| {
error!("error: {}", e); error!("error: {}", e);
@ -598,27 +476,9 @@ impl server::Service for Service {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{HtmlEscaped, Humanized, Segments}; use super::Segments;
use testutil; 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 &lt;tag> &amp; text", format!("{}", HtmlEscaped("a <tag> & text")));
}
#[test] #[test]
fn test_segments() { fn test_segments() {
testutil::init(); testutil::init();
@ -662,8 +522,9 @@ mod bench {
::std::thread::spawn(move || { ::std::thread::spawn(move || {
let addr = "127.0.0.1:0".parse().unwrap(); let addr = "127.0.0.1:0".parse().unwrap();
let (db, dir) = (db.db.clone(), db.dir.clone()); 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() let server = hyper::server::Http::new()
.bind(&addr, move || Ok(super::Service::new(db.clone(), dir.clone()))) .bind(&addr, move || Ok(service.clone()))
.unwrap(); .unwrap();
tx.send(server.local_addr().unwrap()).unwrap(); tx.send(server.local_addr().unwrap()).unwrap();
server.run().unwrap(); server.run().unwrap();
@ -678,10 +539,10 @@ mod bench {
} }
#[bench] #[bench]
fn serve_camera_html(b: &mut Bencher) { fn serve_camera_recordings(b: &mut Bencher) {
testutil::init(); testutil::init();
let server = &*SERVER; 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(); *testutil::TEST_CAMERA_UUID)).unwrap();
let mut buf = Vec::new(); let mut buf = Vec::new();
let client = reqwest::Client::new().unwrap(); let client = reqwest::Client::new().unwrap();

81
ui-src/index.html Normal file
View File

@ -0,0 +1,81 @@
<!DOCTYPE html>
<!-- vim: set et: -->
<html lang="en">
<head>
<title>moonfire ui</title>
<script src="bundle.js"></script>
<style type="text/css">
#nav {
position: fixed;
left: 0px;
top: 0px;
width: 17em;
}
.ui-datepicker { width: 100%; }
#videos {
margin-left: 18em;
}
#videos tbody:after {
content: '';
display: block;
height: 3ex;
}
tbody .name {
background-color: #eee;
}
tr.r:hover { background-color: #ddd; }
tr.r th, tr.r td { padding: 0.5ex 1.5em; text-align: right; }
.ui-dialog .ui-dialog-content {
overflow: visible; /* remove stupid scroll bars when resizing. */
padding: 0;
}
video {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="nav">
<form action="#">
<fieldset id="cameras">
<legend>Cameras</legend>
</fieldset>
<fieldset id="datetime">
<legend>Datetime range</legend>
<div id="start-date"></div>
<input id="start-time" name="start-time" type="text" title="Starting
time within the day. Blank for the beginning of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour."><br>
<input type="radio" name="end-date-type" id="end-date-same" checked>
<label for="end-date-same">to same day</label><br>
<input type="radio" name="end-date-type" id="end-date-other">
<label for="end-date-other">to other day</label>
<div id="end-date"></div>
<input id="end-time" name="end-time" type="text" title="Ending
time within the day. Blank for the end of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour."><br>
</fieldset>
<label for="split">Max video length</label>
<select name="split" id="split">
<option value="324000000">1 hour</option>
<option value="1296000000">4 hours</option>
<option value="7776000000">24 hours</option>
<option value="">infinite</option>
</select><br>
<input type="checkbox" checked id="trim" name="trim">
<label for="trim">Trim segment start/end</label><br>
<input type="checkbox" checked id="ts" name="ts">
<label for="ts">Timestamp track</label>
</form>
</div>
<table id="videos"></table>
</body>
</html>

386
ui-src/index.js Normal file
View File

@ -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 = $('<video controls preload="auto" autoplay="true"/>');
let dialog = $('<div class="playdialog"/>').append(video);
$("body").append(dialog);
// Format start and end times for the dialog title. If they're the same day,
// abbreviate the end time.
let formattedStart = formatTime(startTime90k);
let formattedEnd = formatTime(endTime90k);
let timePos = 'YYYY-mm-ddT'.length;
if (formattedEnd.startsWith(formattedStart.substr(0, timePos))) {
formattedEnd = formattedEnd.substr(timePos);
}
dialog.dialog({
title: camera.shortName + ", " + formattedStart + " to " + formattedEnd,
width: recording.videoSampleEntryWidth / 4,
close: function() { dialog.remove(); },
});
video.attr("src", url);
}
function formatRecordings(camera) {
let tbody = $("#tab-" + camera.uuid);
$(".loading", tbody).hide();
$(".r", tbody).remove();
const frameRateFmt = new Intl.NumberFormat([], {maximumFractionDigits: 0});
const sizeFmt = new Intl.NumberFormat([], {maximumFractionDigits: 1});
const trim = $("#trim").prop("checked");
for (let recording of camera.recordingsData.recordings) {
const duration = (recording.endTime90k - recording.startTime90k) / 90000;
let row = $('<tr class="r"/>');
const startTime90k = trim && recording.startTime90k < camera.recordingsRange.startTime90k
? camera.recordingsRange.startTime90k : recording.startTime90k;
const endTime90k = trim && recording.endTime90k > camera.recordingsRange.endTime90k
? camera.recordingsRange.endTime90k : recording.endTime90k;
let formattedStart = formatTime(startTime90k);
let formattedEnd = formatTime(endTime90k);
const singleDateStr = camera.recordingsRange.singleDateStr;
if (singleDateStr !== null && formattedStart.startsWith(singleDateStr)) {
formattedStart = formattedStart.substr(11);
}
if (singleDateStr !== null && formattedEnd.startsWith(singleDateStr)) {
formattedEnd = formattedEnd.substr(11);
}
row.append(
$("<td/>").text(formattedStart),
$("<td/>").text(formattedEnd),
$("<td/>").text(recording.videoSampleEntryWidth + "x" + recording.videoSampleEntryHeight),
$("<td/>").text(frameRateFmt.format(recording.videoSamples / duration)),
$("<td/>").text(sizeFmt.format(recording.sampleFileBytes / 1048576) + " MB"),
$("<td/>").text(sizeFmt.format(recording.sampleFileBytes / duration * .000008) + " Mbps"));
row.on("click", function() { onSelectVideo(camera, camera.recordingsRange, recording); });
tbody.append(row);
}
};
function reselectDateRange(startDateStr, endDateStr) {
selectedRange.startDateStr = startDateStr;
selectedRange.endDateStr = endDateStr;
selectedRange.startTime90k = parseDateTime(startDateStr, selectedRange.startTimeStr, false);
selectedRange.endTime90k = parseDateTime(endDateStr, selectedRange.endTimeStr, true);
fetch();
}
// Run when selectedRange is populated/changed or when split changes.
function fetch() {
console.log('Fetching ', formatTime(selectedRange.startTime90k), ' to ',
formatTime(selectedRange.endTime90k));
let split = $("#split").val();
for (let camera of cameras) {
let url = apiUrl + 'cameras/' + camera.uuid + '/recordings?startTime90k=' +
selectedRange.startTime90k + '&endTime90k=' + selectedRange.endTime90k;
if (split !== '') {
url += '&split90k=' + split;
}
if (url === camera.recordingsUrl) {
continue; // nothing to do.
}
if (camera.recordingsReq !== null) {
camera.recordingsReq.abort();
}
let tbody = $("#tab-" + camera.uuid);
$(".r", tbody).remove();
$(".loading", tbody).show();
let r = req(url);
camera.recordingsUrl = url;
camera.recordingsRange = selectedRange;
camera.recordingsReq = r;
r.always(function() { camera.recordingsReq = null; });
r.then(function(data, status, req) {
// Sort recordings in descending order.
data.recordings.sort(function(a, b) { return b.startId - a.startId; });
camera.recordingsData = data;
formatRecordings(camera);
}).catch(function(data, status, err) {
console.log(url, ' load failed: ', status, ': ', err);
});
}
}
// Run initially and when changing camera filter.
function setupCalendar() {
let merged = {};
for (const camera of cameras) {
if (!camera.enabled) {
continue;
}
for (const dateStr in camera.days) {
merged[dateStr] = true;
}
}
let minDateStr = '9999-99-99';
let maxDateStr = '0000-00-00';
for (const dateStr in merged) {
if (dateStr > maxDateStr) {
maxDateStr = dateStr;
}
if (dateStr < minDateStr) {
minDateStr = dateStr;
}
}
let from = $("#start-date");
let to = $("#end-date");
let beforeShowDay = function(date) {
let dateStr = date.toISOString().substr(0, 10);
return [dateStr in merged, "", ""];
}
if ($("#end-date-same").prop("checked")) {
from.datepicker("option", {
dateFormat: $.datepicker.ISO_8601,
minDate: minDateStr,
maxDate: maxDateStr,
onSelect: function(dateStr, picker) {
reselectDateRange(dateStr, dateStr);
},
beforeShowDay: beforeShowDay,
disabled: false,
});
to.datepicker("destroy");
to.datepicker({disabled: true});
} else {
from.datepicker("option", {
dateFormat: $.datepicker.ISO_8601,
minDate: minDateStr,
onSelect: function(dateStr, picker) {
to.datepicker("option", "minDate", from.datepicker("getDate").toISOString().substr(0, 10));
reselectDateRange(dateStr, to.datepicker("getDate").toISOString().substr(0, 10));
},
beforeShowDay: beforeShowDay,
disabled: false,
});
to.datepicker("option", {
dateFormat: $.datepicker.ISO_8601,
minDate: from.datepicker("getDate"),
maxDate: maxDateStr,
onSelect: function(dateStr, picker) {
from.datepicker("option", "maxDate", to.datepicker("getDate").toISOString().substr(0, 10));
reselectDateRange(from.datepicker("getDate").toISOString().substr(0, 10), dateStr);
},
beforeShowDay: beforeShowDay,
disabled: false,
});
to.datepicker("setDate", from.datepicker("getDate"));
from.datepicker("option", {maxDate: to.datepicker("getDate")});
}
const date = from.datepicker("getDate");
if (date !== null) {
const dateStr = date.toISOString().substr(0, 10);
reselectDateRange(dateStr, dateStr);
}
};
function onCameraChange(event, camera) {
camera.enabled = event.target.checked;
if (camera.enabled) {
$("#tab-" + camera.uuid).show();
} else {
$("#tab-" + camera.uuid).hide();
}
console.log('Camera ', camera.shortName, camera.enabled ? 'enabled' : 'disabled');
setupCalendar();
}
// Parses the given date and time string into a valid time90k or null.
function parseDateTime(dateStr, timeStr, isEnd) {
// Match HH:mm[:ss[:FFFFF]][+-HH:mm]
// Group 1 is the hour and minute (HH:mm).
// Group 2 is the seconds (:ss), if any.
// Group 3 is the fraction (FFFFF), if any.
// Group 4 is the zone (+-HH:mm), if any.
const timeRe =
/^([0-9]{1,2}:[0-9]{2})(?:(:[0-9]{2})(?::([0-9]{5}))?)?([+-][0-9]{1,2}:?(?:[0-9]{2})?)?$/;
if (timeStr === '') {
const m = moment.tz(dateStr, zone);
if (isEnd) {
m.add({days: 1});
}
return m.valueOf() * 90;
}
const match = timeRe.exec(timeStr);
if (match === null) {
return null;
}
const orBlank = function(s) { return s === undefined ? "" : s; };
const datetimeStr = dateStr + 'T' + match[1] + orBlank(match[2]) + orBlank(match[4]);
const m = moment.tz(datetimeStr, zone);
if (!m.isValid()) {
return null;
}
const frac = match[3] === undefined ? 0 : parseInt(match[3], 10);
return m.valueOf() * 90 + frac;
}
function onTimeChange(e, isEnd) {
let parsed = parseDateTime(isEnd ? selectedRange.endDateStr : selectedRange.startDateStr,
e.target.value, isEnd);
if (parsed == null) {
console.log('bad time change');
$(e.target).addClass('ui-state-error');
return;
}
$(e.target).removeClass('ui-state-error');
console.log(isEnd ? "end" : "start", ' time change to: ', parsed, ' (', formatTime(parsed), ')');
if (isEnd) {
selectedRange.endTimeStr = e.target.value;
selectedRange.endTime90k = parsed;
} else {
selectedRange.startTimeStr = e.target.value;
selectedRange.startTime90k = parsed;
}
fetch();
}
function onReceivedCameras(data) {
let fieldset = $("#cameras");
if (data.cameras.length === 0) {
return;
}
var reqs = [];
let videos = $("#videos");
for (let camera of data.cameras) {
const id = "cam-" + camera.uuid;
let checkBox = $('<input type="checkbox" checked>').attr("name", id).attr("id", id);
checkBox.change(function(event) { onCameraChange(event, camera); });
fieldset.append(checkBox,
$("<label/>").attr("for", id).text(camera.shortName),
$("<br/>"));
let tab = $("<tbody>").attr("id", "tab-" + camera.uuid);
tab.append(
$('<tr class="name">').append($('<th colspan=6/>').text(camera.shortName)),
$('<tr class="hdr"><th>start</th><th>end</th><th>resolution</th><th>fps</th><th>size</th><th>bitrate</th></tr>'),
$('<tr class="loading"><td colspan=6>loading...</td></tr>'));
videos.append(tab);
camera.enabled = true;
camera.recordingsUrl = null;
camera.recordingsRange = null;
camera.recordingsData = null;
camera.recordingsReq = null;
}
$("#end-date-same").change(function(e) { setupCalendar(); });
$("#end-date-other").change(function(e) { setupCalendar(); });
$("#start-date").datepicker({disabled: true});
$("#end-date").datepicker({disabled: true});
$("#start-time").change(function(e) { onTimeChange(e, false); });
$("#end-time").change(function(e) { onTimeChange(e, true); });
$("#split").change(function(e) {
if (selectedRange.startTime90k !== null) {
fetch();
}
});
$("#trim").change(function(e) {
// Changing the trim doesn't need to refetch data, but it does need to
// reformat the tables.
let newTrim = e.target.checked;
for (camera of cameras) {
if (camera.recordingsData !== null) {
formatRecordings(camera);
}
}
});
zone = data.timeZoneName;
cameras = data.cameras;
console.log('Loaded cameras.');
setupCalendar();
}
$(function() {
$(document).tooltip();
req(apiUrl + '?days=true').then(function(data, status, req) {
onReceivedCameras(data);
}).catch(function(data, status, err) {
console.log('cameras load failed: ', status, err);
});
});

86
ui/index.html Normal file
View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<!-- vim: set et: -->
<html lang="en">
<head>
<title>moonfire ui</title>
<link href="jquery-ui.css" rel="stylesheet">
<script src="jquery.js"></script>
<script src="moment.js"></script>
<script src="../moment-timezone-with-data-2012-2022.js"></script>
<script src="jquery-ui.js"></script>
<script src="moonfire-ui.js"></script>
<style type="text/css">
#nav {
position: fixed;
left: 0px;
top: 0px;
width: 17em;
}
.ui-datepicker { width: 100%; }
#videos {
margin-left: 18em;
}
#videos tbody:after {
content: '';
display: block;
height: 3ex;
}
tbody .name {
background-color: #eee;
}
tr.r:hover { background-color: #ddd; }
tr.r th, tr.r td { padding: 0.5ex 1.5em; text-align: right; }
.ui-dialog .ui-dialog-content {
overflow: visible; /* remove stupid scroll bars when resizing. */
padding: 0;
}
video {
width: 100%;
height: 100%;
}
</style>
</head>
<body onload="onLoad()">
<div id="nav">
<form action="#">
<fieldset id="cameras">
<legend>Cameras</legend>
</fieldset>
<fieldset id="datetime">
<legend>Datetime range</legend>
<div id="start-date"></div>
<input id="start-time" name="start-time" type="text" title="Starting
time within the day. Blank for the beginning of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour."><br>
<input type="radio" name="end-date-type" id="end-date-same" checked>
<label for="to-same-date">to same day</label><br>
<input type="radio" name="end-date-type" id="end-date-other">
<label for="end-date-other">to other day</label>
<div id="end-date"></div>
<input id="end-time" name="end-time" type="text" title="Ending
time within the day. Blank for the end of the day. Otherwise
HH:mm[:ss[:FFFFF]][+-HH:mm], where F is 90,000ths of a second.
Timezone is normally left out; it's useful once a year during the
ambiguous times of the &quot;fall back&quot; hour."><br>
</fieldset>
<label for="split">Max video length</label>
<select name="split" id="split">
<option value="324000000">1 hour</option>
<option value="1296000000">4 hours</option>
<option value="7776000000">24 hours</option>
<option value="">infinite</option>
</select><br>
<input type="checkbox" checked id="trim" name="trim">
<label for="trim">Trim segment start/end</label><br>
<input type="checkbox" checked id="ts" name="ts">
<label for="ts">Timestamp track</label>
</form>
</div>
<table id="videos"></table>
</body>
</html>

27
webpack.config.js Normal file
View File

@ -0,0 +1,27 @@
const path = require('path');
const webpack = require('webpack');
const MinifyPlugin = require("babel-minify-webpack-plugin");
module.exports = {
entry: './ui-src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'ui-dist')
},
module: {
loaders: [
{ test: /\.png$/, loader: "file-loader" },
{ test: /\.css$/, loader: "style-loader!css-loader" },
]
},
plugins: [
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment\/moment\.js$/,
'./min/moment.min.js'),
new webpack.IgnorePlugin(/\.\/locale$/),
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment-timezone\/index\.js$/,
'./builds/moment-timezone-with-data-2012-2022.min.js'),
new MinifyPlugin({}, {})
]
};

3510
yarn.lock Normal file

File diff suppressed because it is too large Load Diff