config: validate more in cameras dialog box

Fixes #152

This won't win any awards for best UI or cleanest UI code, but it's an
improvement. Long-term I want the web config UI instead.
This commit is contained in:
Scott Lamb 2021-08-23 15:55:47 -07:00
parent 30cea5cfcb
commit 95dec9791c
5 changed files with 85 additions and 36 deletions

1
server/Cargo.lock generated
View File

@ -1254,6 +1254,7 @@ dependencies = [
"tempfile", "tempfile",
"time", "time",
"tokio", "tokio",
"url",
"uuid", "uuid",
] ]

View File

@ -41,6 +41,7 @@ smallvec = "1.0"
tempfile = "3.2.0" tempfile = "3.2.0"
time = "0.1" time = "0.1"
tokio = { version = "1.0", features = ["macros", "parking_lot", "rt-multi-thread", "sync"] } tokio = { version = "1.0", features = ["macros", "parking_lot", "rt-multi-thread", "sync"] }
url = "2.1.1"
uuid = { version = "0.8", features = ["std", "v4"] } uuid = { version = "0.8", features = ["std", "v4"] }
itertools = "0.10.0" itertools = "0.10.0"

View File

@ -54,6 +54,7 @@ use std::str;
use std::string::String; use std::string::String;
use std::sync::Arc; use std::sync::Arc;
use std::vec::Vec; use std::vec::Vec;
use url::Url;
use uuid::Uuid; use uuid::Uuid;
/// Expected schema version. See `guide/schema.md` for more information. /// Expected schema version. See `guide/schema.md` for more information.
@ -499,7 +500,7 @@ pub struct LiveSegment {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct StreamChange { pub struct StreamChange {
pub sample_file_dir_id: Option<i32>, pub sample_file_dir_id: Option<i32>,
pub rtsp_url: String, pub rtsp_url: Option<Url>,
pub record: bool, pub record: bool,
pub flush_if_sec: i64, pub flush_if_sec: i64,
} }
@ -681,7 +682,7 @@ impl StreamStateChanger {
} }
} }
if !have_data if !have_data
&& sc.rtsp_url.is_empty() && sc.rtsp_url.is_none()
&& sc.sample_file_dir_id.is_none() && sc.sample_file_dir_id.is_none()
&& !sc.record && !sc.record
{ {
@ -709,7 +710,7 @@ impl StreamStateChanger {
"#, "#,
)?; )?;
let rows = stmt.execute(named_params! { let rows = stmt.execute(named_params! {
":rtsp_url": &sc.rtsp_url, ":rtsp_url": &sc.rtsp_url.as_ref().map(Url::as_str),
":record": sc.record, ":record": sc.record,
":flush_if_sec": sc.flush_if_sec, ":flush_if_sec": sc.flush_if_sec,
":sample_file_dir_id": sc.sample_file_dir_id, ":sample_file_dir_id": sc.sample_file_dir_id,
@ -723,7 +724,7 @@ impl StreamStateChanger {
streams.push((sid, Some((camera_id, type_, sc)))); streams.push((sid, Some((camera_id, type_, sc))));
} }
} else { } else {
if sc.rtsp_url.is_empty() && sc.sample_file_dir_id.is_none() && !sc.record { if sc.rtsp_url.is_none() && sc.sample_file_dir_id.is_none() && !sc.record {
// Do nothing; there is no record and we want to keep it that way. // Do nothing; there is no record and we want to keep it that way.
continue; continue;
} }
@ -742,7 +743,7 @@ impl StreamStateChanger {
":camera_id": camera_id, ":camera_id": camera_id,
":sample_file_dir_id": sc.sample_file_dir_id, ":sample_file_dir_id": sc.sample_file_dir_id,
":type": type_.as_str(), ":type": type_.as_str(),
":rtsp_url": &sc.rtsp_url, ":rtsp_url": &sc.rtsp_url.as_ref().map(Url::as_str),
":record": sc.record, ":record": sc.record,
":flush_if_sec": sc.flush_if_sec, ":flush_if_sec": sc.flush_if_sec,
})?; })?;
@ -767,7 +768,7 @@ impl StreamStateChanger {
type_, type_,
camera_id, camera_id,
sample_file_dir_id: sc.sample_file_dir_id, sample_file_dir_id: sc.sample_file_dir_id,
rtsp_url: mem::replace(&mut sc.rtsp_url, String::new()), rtsp_url: sc.rtsp_url.take().map(String::from).unwrap_or_default(),
retain_bytes: 0, retain_bytes: 0,
flush_if_sec: sc.flush_if_sec, flush_if_sec: sc.flush_if_sec,
range: None, range: None,
@ -790,10 +791,10 @@ impl StreamStateChanger {
}); });
} }
(Entry::Vacant(_), None) => {} (Entry::Vacant(_), None) => {}
(Entry::Occupied(e), Some((_, _, sc))) => { (Entry::Occupied(e), Some((_, _, mut sc))) => {
let e = e.into_mut(); let e = e.into_mut();
e.sample_file_dir_id = sc.sample_file_dir_id; e.sample_file_dir_id = sc.sample_file_dir_id;
e.rtsp_url = sc.rtsp_url; e.rtsp_url = sc.rtsp_url.take().map(String::from).unwrap_or_default();
e.record = sc.record; e.record = sc.record;
e.flush_if_sec = sc.flush_if_sec; e.flush_if_sec = sc.flush_if_sec;
} }

View File

@ -89,7 +89,7 @@ impl<C: Clocks + Clone> TestDb<C> {
streams: [ streams: [
db::StreamChange { db::StreamChange {
sample_file_dir_id: Some(sample_file_dir_id), sample_file_dir_id: Some(sample_file_dir_id),
rtsp_url: "rtsp://test-camera/main".to_owned(), rtsp_url: Some(url::Url::parse("rtsp://test-camera/main").unwrap()),
record: true, record: true,
flush_if_sec, flush_if_sec,
}, },

View File

@ -5,17 +5,17 @@
use crate::stream::{self, Opener}; use crate::stream::{self, Opener};
use base::strutil::{decode_size, encode_size}; use base::strutil::{decode_size, encode_size};
use cursive::traits::{Boxable, Finder, Identifiable}; use cursive::traits::{Boxable, Finder, Identifiable};
use cursive::views; use cursive::views::{self, ViewRef};
use cursive::Cursive; use cursive::Cursive;
use db::writer; use db::writer;
use failure::Error; use failure::{bail, Error, ResultExt};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use url::Url; use url::Url;
/// Builds a `CameraChange` from an active `edit_camera_dialog`. /// Builds a `CameraChange` from an active `edit_camera_dialog`.
fn get_change(siv: &mut Cursive) -> db::CameraChange { fn get_change(siv: &mut Cursive) -> Result<db::CameraChange, Error> {
// Note: these find_name calls are separate statements, which seems to be important: // Note: these find_name calls are separate statements, which seems to be important:
// https://github.com/gyscos/Cursive/issues/144 // https://github.com/gyscos/Cursive/issues/144
let sn = siv let sn = siv
@ -62,49 +62,75 @@ fn get_change(siv: &mut Cursive) -> db::CameraChange {
streams: Default::default(), streams: Default::default(),
}; };
for &t in &db::ALL_STREAM_TYPES { for &t in &db::ALL_STREAM_TYPES {
let u = siv let rtsp_url = parse_url(
.find_name::<views::EditView>(&format!("{}_rtsp_url", t.as_str())) siv.find_name::<views::EditView>(&format!("{}_rtsp_url", t.as_str()))
.unwrap() .unwrap()
.get_content() .get_content()
.as_str() .as_str(),
.into(); )?;
let r = siv let record = siv
.find_name::<views::Checkbox>(&format!("{}_record", t.as_str())) .find_name::<views::Checkbox>(&format!("{}_record", t.as_str()))
.unwrap() .unwrap()
.is_checked(); .is_checked();
let f = i64::from_str( let flush_if_sec = i64::from_str(
siv.find_name::<views::EditView>(&format!("{}_flush_if_sec", t.as_str())) siv.find_name::<views::EditView>(&format!("{}_flush_if_sec", t.as_str()))
.unwrap() .unwrap()
.get_content() .get_content()
.as_str(), .as_str(),
) )
.unwrap_or(0); .unwrap_or(0);
let d = *siv let sample_file_dir_id = *siv
.find_name::<views::SelectView<Option<i32>>>(&format!("{}_sample_file_dir", t.as_str())) .find_name::<views::SelectView<Option<i32>>>(&format!("{}_sample_file_dir", t.as_str()))
.unwrap() .unwrap()
.selection() .selection()
.unwrap(); .unwrap();
c.streams[t.index()] = db::StreamChange { c.streams[t.index()] = db::StreamChange {
rtsp_url: u, rtsp_url,
sample_file_dir_id: d, sample_file_dir_id,
record: r, record,
flush_if_sec: f, flush_if_sec,
}; };
} }
c Ok(c)
}
/// Attempts to parse a URL field into a sort-of-validated URL.
fn parse_url(raw: &str) -> Result<Option<Url>, Error> {
if raw.is_empty() {
return Ok(None);
}
let url = url::Url::parse(&raw).with_context(|_| format!("can't parse {:?} as URL", &raw))?;
if url.scheme() != "rtsp" {
bail!("Expected URL scheme rtsp:// in URL {}", &url);
}
if !url.username().is_empty() || url.password().is_some() {
bail!(
"Unexpected credentials in URL {}; use the username and password fields instead",
&url
);
}
Ok(Some(url))
} }
fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) { fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
let change = get_change(siv); let result = (|| {
let change = get_change(siv)?;
let result = { for (i, stream) in change.streams.iter().enumerate() {
if stream.record && (stream.rtsp_url.is_none() || stream.sample_file_dir_id.is_none()) {
let type_ = db::StreamType::from_index(i).unwrap();
bail!(
"Can't record {} stream without RTSP URL and sample file directory",
type_.as_str()
);
}
}
let mut l = db.lock(); let mut l = db.lock();
if let Some(id) = id { if let Some(id) = id {
l.update_camera(id, change) l.update_camera(id, change)
} else { } else {
l.add_camera(change).map(|_| ()) l.add_camera(change).map(|_| ())
} }
}; })();
if let Err(e) = result { if let Err(e) = result {
siv.add_layer( siv.add_layer(
views::Dialog::text(format!("Unable to add camera: {}", e)) views::Dialog::text(format!("Unable to add camera: {}", e))
@ -140,18 +166,21 @@ fn press_test_inner(
} }
fn press_test(siv: &mut Cursive, t: db::StreamType) { fn press_test(siv: &mut Cursive, t: db::StreamType) {
let c = get_change(siv); let mut c = match get_change(siv) {
let url = match Url::parse(&c.streams[t.index()].rtsp_url) {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
siv.add_layer( siv.add_layer(
views::Dialog::text(format!("Unparseable URL: {}", e)) views::Dialog::text(format!("{}", e))
.title("Stream test failed") .title("Stream test failed")
.dismiss_button("Back"), .dismiss_button("Back"),
); );
return; return;
} }
}; };
let url = c.streams[t.index()]
.rtsp_url
.take()
.expect("test button only enabled when URL set");
let username = c.username; let username = c.username;
let password = c.password; let password = c.password;
@ -317,6 +346,11 @@ fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
} }
} }
fn edit_url(content: &str, mut test_button: ViewRef<views::Button>) {
let enable_test = matches!(parse_url(content), Ok(Some(_)));
test_button.set_enabled(enable_test);
}
/// Adds or updates a camera. /// Adds or updates a camera.
/// (The former if `item` is None; the latter otherwise.) /// (The former if `item` is None; the latter otherwise.)
fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i32>) { fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i32>) {
@ -358,13 +392,21 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
views::LinearLayout::horizontal() views::LinearLayout::horizontal()
.child( .child(
views::EditView::new() views::EditView::new()
.on_edit(move |siv, content, _pos| {
let test_button = siv
.find_name::<views::Button>(&format!("{}_test", type_.as_str()))
.unwrap();
edit_url(content, test_button)
})
.with_name(format!("{}_rtsp_url", type_.as_str())) .with_name(format!("{}_rtsp_url", type_.as_str()))
.full_width(), .full_width(),
) )
.child(views::DummyView) .child(views::DummyView)
.child(views::Button::new("Test", move |siv| { .child(
press_test(siv, type_) views::Button::new("Test", move |siv| press_test(siv, type_))
})), .disabled()
.with_name(format!("{}_test", type_.as_str())),
),
) )
.child( .child(
"sample file dir", "sample file dir",
@ -431,6 +473,10 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
&format!("{}_rtsp_url", t.as_str()), &format!("{}_rtsp_url", t.as_str()),
|v: &mut views::EditView| v.set_content(s.rtsp_url.to_owned()), |v: &mut views::EditView| v.set_content(s.rtsp_url.to_owned()),
); );
let test_button = dialog
.find_name::<views::Button>(&format!("{}_test", t.as_str()))
.unwrap();
edit_url(&s.rtsp_url, test_button);
dialog.call_on_name( dialog.call_on_name(
&format!("{}_usage_cap", t.as_str()), &format!("{}_usage_cap", t.as_str()),
|v: &mut views::TextView| v.set_content(u), |v: &mut views::TextView| v.set_content(u),