add matching time parsing and formatting routines

* add a --ts subcommand to convert between numeric and human-readable
  representations. This is handy when directly inspecting the SQLite database
  or API output.
* also take the human-readable form in the web interface's camera view.
* to reduce confusion, when using trim=true on the web interface's camera
  view, trim the displayed starting and ending times as well as the actual
  .mp4 file links.
This commit is contained in:
Scott Lamb
2017-01-12 23:09:02 -08:00
parent c96f306e18
commit a6ec68027a
5 changed files with 205 additions and 19 deletions

View File

@@ -93,6 +93,7 @@ const USAGE: &'static str = "
Usage: moonfire-nvr [options]
moonfire-nvr --upgrade [options]
moonfire-nvr --check [options]
moonfire-nvr --ts <ts>...
moonfire-nvr (--help | --version)
Options:
@@ -127,8 +128,10 @@ struct Args {
flag_read_only: bool,
flag_check: bool,
flag_upgrade: bool,
flag_ts: bool,
flag_no_vacuum: bool,
flag_preset_journal: String,
arg_ts: Vec<String>,
}
fn main() {
@@ -171,11 +174,21 @@ fn main() {
upgrade::run(conn, &args.flag_preset_journal, args.flag_no_vacuum).unwrap();
} else if args.flag_check {
check::run(conn, &args.flag_sample_file_dir).unwrap();
} else if args.flag_ts {
run_ts(args.arg_ts).unwrap();
} else {
run(args, conn, &signal);
}
}
fn run_ts(timestamps: Vec<String>) -> Result<(), error::Error> {
for timestamp in &timestamps {
let t = recording::Time::parse(timestamp)?;
println!("{} == {}", t, t.0);
}
Ok(())
}
fn run(args: Args, conn: rusqlite::Connection, signal: &chan::Receiver<chan_signal::Signal>) {
let db = Arc::new(db::Database::new(conn).unwrap());
let dir = dir::SampleFileDir::new(&args.flag_sample_file_dir, db.clone()).unwrap();

View File

@@ -31,9 +31,11 @@
extern crate uuid;
use coding::{append_varint32, decode_varint32, unzigzag32, zigzag32};
use core::str::FromStr;
use db;
use std::ops;
use error::Error;
use regex::Regex;
use std::ops;
use std::fmt;
use std::ops::Range;
use std::string::String;
@@ -53,6 +55,74 @@ impl Time {
Time(tm.sec * TIME_UNITS_PER_SEC + tm.nsec as i64 * TIME_UNITS_PER_SEC / 1_000_000_000)
}
/// Parses a time as either 90,000ths of a second since epoch or a RFC 3339-like string.
///
/// The former is 90,000ths of a second since 1970-01-01T00:00:00 UTC, excluding leap seconds.
///
/// The latter is a string such as `2006-01-02T15:04:05`, followed by an optional 90,000ths of
/// a second such as `:00001`, followed by an optional time zone offset such as `Z` or
/// `-07:00`. A missing fraction is assumed to be 0. A missing time zone offset implies the
/// local time zone.
pub fn parse(s: &str) -> Result<Self, Error> {
lazy_static! {
static ref RE: Regex = Regex::new(r#"(?x)
^
([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})
(?::([0-9]{5}))?
(Z|[+-]([0-9]{2}):([0-9]{2}))?
$"#).unwrap();
}
// First try parsing as 90,000ths of a second since epoch.
match i64::from_str(s) {
Ok(i) => return Ok(Time(i)),
Err(_) => {},
}
// If that failed, parse as a time string or bust.
let c = RE.captures(s).ok_or_else(|| Error::new(format!("unparseable time {:?}", s)))?;
let mut tm = time::Tm{
tm_sec: i32::from_str(c.get(6).unwrap().as_str()).unwrap(),
tm_min: i32::from_str(c.get(5).unwrap().as_str()).unwrap(),
tm_hour: i32::from_str(c.get(4).unwrap().as_str()).unwrap(),
tm_mday: i32::from_str(c.get(3).unwrap().as_str()).unwrap(),
tm_mon: i32::from_str(c.get(2).unwrap().as_str()).unwrap(),
tm_year: i32::from_str(c.get(1).unwrap().as_str()).unwrap(),
tm_wday: 0,
tm_yday: 0,
tm_isdst: -1,
tm_utcoff: 0,
tm_nsec: 0,
};
if tm.tm_mon == 0 {
return Err(Error::new(format!("time {:?} has month 0", s)));
}
tm.tm_mon -= 1;
if tm.tm_year < 1900 {
return Err(Error::new(format!("time {:?} has year before 1900", s)));
}
tm.tm_year -= 1900;
// The time crate doesn't use tm_utcoff properly; it just calls timegm() if tm_utcoff == 0,
// mktime() otherwise. If a zone is specified, use the timegm path and a manual offset.
// If no zone is specified, use the tm_utcoff path. This is pretty lame, but follow the
// chrono crate's lead and just use 0 or 1 to choose between these functions.
let sec = if let Some(zone) = c.get(8) {
tm.to_timespec().sec + if zone.as_str() == "Z" {
0
} else {
let off = i64::from_str(c.get(9).unwrap().as_str()).unwrap() * 3600 +
i64::from_str(c.get(10).unwrap().as_str()).unwrap() * 60;
if zone.as_str().as_bytes()[0] == b'-' { off } else { -off }
}
} else {
tm.tm_utcoff = 1;
tm.to_timespec().sec
};
let fraction = if let Some(f) = c.get(7) { i64::from_str(f.as_str()).unwrap() } else { 0 };
Ok(Time(sec * TIME_UNITS_PER_SEC + fraction))
}
pub fn unix_seconds(&self) -> i64 { self.0 / TIME_UNITS_PER_SEC }
}
@@ -78,8 +148,10 @@ impl ops::Sub<Duration> for Time {
impl fmt::Display for Time {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let tm = time::at(time::Timespec{sec: self.0 / TIME_UNITS_PER_SEC, nsec: 0});
write!(f, "{}:{:05}", tm.strftime("%FT%T%Z").or_else(|_| Err(fmt::Error))?,
self.0 % TIME_UNITS_PER_SEC)
let zone_minutes = tm.tm_utcoff.abs() / 60;
write!(f, "{}:{:05}{}{:02}:{:02}", tm.strftime("%FT%T").or_else(|_| Err(fmt::Error))?,
self.0 % TIME_UNITS_PER_SEC,
if tm.tm_utcoff > 0 { '+' } else { '-' }, zone_minutes / 60, zone_minutes % 60)
}
}
@@ -422,6 +494,28 @@ mod tests {
use super::*;
use testutil::TestDb;
#[test]
fn test_parse_time() {
let tests = &[
("2006-01-02T15:04:05-07:00", 102261550050000),
("2006-01-02T15:04:05:00001-07:00", 102261550050001),
("2006-01-02T15:04:05-08:00", 102261874050000),
("2006-01-02T15:04:05", 102261874050000), // implied -08:00
("2006-01-02T15:04:05:00001", 102261874050001), // implied -08:00
("2006-01-02T15:04:05-00:00", 102259282050000),
("2006-01-02T15:04:05Z", 102259282050000),
("102261550050000", 102261550050000),
];
for test in tests {
assert_eq!(test.1, Time::parse(test.0).unwrap().0, "parsing {}", test.0);
}
}
#[test]
fn test_format_time() {
assert_eq!("2006-01-02T15:04:05:00000-08:00", format!("{}", Time(102261874050000)));
}
#[test]
fn test_display_duration() {
let tests = &[

View File

@@ -198,21 +198,21 @@ struct Segments {
impl Segments {
pub fn parse(input: &str) -> Result<Segments, ()> {
let caps = SEGMENTS_RE.captures(input).ok_or(())?;
let ids_start = i32::from_str(caps.at(1).unwrap()).map_err(|_| ())?;
let ids_end = match caps.at(2) {
Some(e) => i32::from_str(&e[1..]).map_err(|_| ())?,
let ids_start = i32::from_str(caps.get(1).unwrap().as_str()).map_err(|_| ())?;
let ids_end = match caps.get(2) {
Some(e) => i32::from_str(&e.as_str()[1..]).map_err(|_| ())?,
None => ids_start,
} + 1;
if ids_start < 0 || ids_end <= ids_start {
return Err(());
}
let start_time = caps.at(3).map_or(Ok(0), i64::from_str).map_err(|_| ())?;
let start_time = caps.get(3).map_or(Ok(0), |m| i64::from_str(m.as_str())).map_err(|_| ())?;
if start_time < 0 {
return Err(());
}
let end_time = match caps.at(4) {
let end_time = match caps.get(4) {
Some(v) => {
let e = i64::from_str(v).map_err(|_| ())?;
let e = i64::from_str(v.as_str()).map_err(|_| ())?;
if e <= start_time {
return Err(());
}
@@ -309,19 +309,18 @@ impl Handler {
fn camera_html(&self, db: MutexGuard<db::LockedDatabase>, query: &str,
uuid: Uuid) -> Result<Vec<u8>, Error> {
let (r, trim) = {
let mut start = i64::min_value();
let mut end = i64::max_value();
let mut time = recording::Time(i64::min_value()) .. recording::Time(i64::max_value());
let mut trim = false;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
let (key, value) = (key.borrow(), value.borrow());
match key {
"start_time_90k" => start = i64::from_str(value)?,
"end_time_90k" => end = i64::from_str(value)?,
"start_time" => time.start = recording::Time::parse(value)?,
"end_time" => time.end = recording::Time::parse(value)?,
"trim" if value == "true" => trim = true,
_ => {},
}
};
(recording::Time(start) .. recording::Time(end), trim)
(time, trim)
};
let camera = db.get_camera(uuid)
.ok_or_else(|| Error::new("no such camera".to_owned()))?;
@@ -383,12 +382,13 @@ impl Handler {
}
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(row.time.start)),
HumanizedTimestamp(Some(row.time.end)), row.video_sample_entry.width,
row.video_sample_entry.height,
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 }))?;
@@ -540,6 +540,7 @@ impl Handler {
impl server::Handler for Handler {
fn handle(&self, req: server::Request, res: server::Response) {
let (path, query) = get_path_and_query(&req.uri);
error!("path={:?}, query={:?}", path, query);
let res = match decode_path(path) {
Path::CamerasList => self.list_cameras(&req, res),
Path::Camera(uuid) => self.camera(uuid, query, &req, res),