mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-12-08 00:32:26 -05:00
support multiple sample file directories
This is still pretty basic support. There's no config UI support for renaming/moving the sample file directories after they are created, and no error checking that the files are still in the expected place. I can imagine sysadmins getting into trouble trying to change things. I hope to address at least some of that in a follow-up change to introduce a versioning/locking scheme that ensures databases and sample file dirs match in some way. A bonus change that kinda got pulled along for the ride: a dialog pops up in the config UI while a stream is being tested. The experience was pretty bad before; there was no indication the button worked at all until it was done, sometimes many seconds later.
This commit is contained in:
@@ -36,6 +36,7 @@ use self::cursive::views;
|
||||
use db;
|
||||
use dir;
|
||||
use error::Error;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use stream::{self, Opener, Stream};
|
||||
use super::{decode_size, encode_size};
|
||||
@@ -49,22 +50,33 @@ fn get_change(siv: &mut Cursive) -> db::CameraChange {
|
||||
let h = siv.find_id::<views::EditView>("host").unwrap().get_content().as_str().into();
|
||||
let u = siv.find_id::<views::EditView>("username").unwrap().get_content().as_str().into();
|
||||
let p = siv.find_id::<views::EditView>("password").unwrap().get_content().as_str().into();
|
||||
let m = siv.find_id::<views::EditView>("main_rtsp_path").unwrap().get_content().as_str().into();
|
||||
let s = siv.find_id::<views::EditView>("sub_rtsp_path").unwrap().get_content().as_str().into();
|
||||
db::CameraChange {
|
||||
let mut c = db::CameraChange {
|
||||
short_name: sn,
|
||||
description: d,
|
||||
host: h,
|
||||
username: u,
|
||||
password: p,
|
||||
rtsp_paths: [m, s],
|
||||
streams: Default::default(),
|
||||
};
|
||||
for &t in &db::ALL_STREAM_TYPES {
|
||||
let p = siv.find_id::<views::EditView>(&format!("{}_rtsp_path", t.as_str()))
|
||||
.unwrap().get_content().as_str().into();
|
||||
let r = siv.find_id::<views::Checkbox>(&format!("{}_record", t.as_str()))
|
||||
.unwrap().is_checked();
|
||||
let d = *siv.find_id::<views::SelectView<Option<i32>>>(
|
||||
&format!("{}_sample_file_dir", t.as_str()))
|
||||
.unwrap().selection();
|
||||
c.streams[t.index()] = db::StreamChange {
|
||||
rtsp_path: p,
|
||||
sample_file_dir_id: d,
|
||||
record: r,
|
||||
};
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>,
|
||||
id: Option<i32>) {
|
||||
fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
|
||||
let change = get_change(siv);
|
||||
siv.pop_layer(); // get rid of the add/edit camera dialog.
|
||||
|
||||
let result = {
|
||||
let mut l = db.lock();
|
||||
@@ -79,9 +91,11 @@ fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFi
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"));
|
||||
} else {
|
||||
siv.pop_layer(); // get rid of the add/edit camera dialog.
|
||||
|
||||
// Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry.
|
||||
siv.pop_layer();
|
||||
add_dialog(db, dir, siv);
|
||||
top_dialog(db, siv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,25 +105,44 @@ fn press_test_inner(url: &str) -> Result<String, Error> {
|
||||
Ok(format!("{}x{} video stream", extra_data.width, extra_data.height))
|
||||
}
|
||||
|
||||
fn press_test(siv: &mut Cursive, c: &db::CameraChange, stream: &str, path: &str) {
|
||||
let url = format!("rtsp://{}:{}@{}{}", c.username, c.password, c.host, path);
|
||||
let description = match press_test_inner(&url) {
|
||||
Err(e) => {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("{} stream at {}:\n\n{}", stream, url, e))
|
||||
.title("Stream test failed")
|
||||
.dismiss_button("Back"));
|
||||
return;
|
||||
},
|
||||
Ok(d) => d,
|
||||
};
|
||||
siv.add_layer(views::Dialog::text(format!("{} stream at {}:\n\n{}", stream, url, description))
|
||||
.title("Stream test succeeded")
|
||||
.dismiss_button("Back"));
|
||||
fn press_test(siv: &mut Cursive, t: db::StreamType) {
|
||||
let c = get_change(siv);
|
||||
let url = format!("rtsp://{}:{}@{}{}", c.username, c.password, c.host,
|
||||
c.streams[t.index()].rtsp_path);
|
||||
siv.add_layer(views::Dialog::text(format!("Testing {} stream at {}. This may take a while \
|
||||
on timeout or if you have a long key frame interval",
|
||||
t.as_str(), url))
|
||||
.title("Testing"));
|
||||
|
||||
// Let siv have this thread for its event loop; do the work in a background thread.
|
||||
// siv.cb_sink doesn't actually wake up the event loop. Tell siv to poll, as a workaround.
|
||||
siv.set_fps(5);
|
||||
let sink = siv.cb_sink().clone();
|
||||
::std::thread::spawn(move || {
|
||||
let r = press_test_inner(&url);
|
||||
sink.send(Box::new(move |siv| {
|
||||
// Polling is no longer necessary.
|
||||
siv.set_fps(0);
|
||||
siv.pop_layer();
|
||||
let description = match r {
|
||||
Err(ref e) => {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("{} stream at {}:\n\n{}", t.as_str(), url, e))
|
||||
.title("Stream test failed")
|
||||
.dismiss_button("Back"));
|
||||
return;
|
||||
},
|
||||
Ok(ref d) => d,
|
||||
};
|
||||
siv.add_layer(views::Dialog::text(
|
||||
format!("{} stream at {}:\n\n{}", t.as_str(), url, description))
|
||||
.title("Stream test succeeded")
|
||||
.dismiss_button("Back"));
|
||||
})).unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
fn press_delete(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, id: i32,
|
||||
name: String, to_delete: i64) {
|
||||
fn press_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, name: String, to_delete: i64) {
|
||||
let dialog = if to_delete > 0 {
|
||||
let prompt = format!("Camera {} has recorded video. Please confirm the amount \
|
||||
of data to delete by typing it back:\n\n{}", name,
|
||||
@@ -120,50 +153,51 @@ fn press_delete(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::Sample
|
||||
.child(views::DummyView)
|
||||
.child(views::EditView::new().on_submit({
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |siv, _| confirm_deletion(siv, &db, &dir, id, to_delete)
|
||||
move |siv, _| confirm_deletion(siv, &db, id, to_delete)
|
||||
}).with_id("confirm")))
|
||||
.button("Delete", {
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |siv| confirm_deletion(siv, &db, &dir, id, to_delete)
|
||||
move |siv| confirm_deletion(siv, &db, id, to_delete)
|
||||
})
|
||||
} else {
|
||||
views::Dialog::text(format!("Delete camera {}? This camera has no recorded video.", name))
|
||||
.button("Delete", {
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |s| actually_delete(s, &db, &dir, id)
|
||||
move |s| actually_delete(s, &db, id)
|
||||
})
|
||||
}.title("Delete camera").dismiss_button("Cancel");
|
||||
siv.add_layer(dialog);
|
||||
}
|
||||
|
||||
fn confirm_deletion(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>,
|
||||
id: i32, to_delete: i64) {
|
||||
fn confirm_deletion(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, to_delete: i64) {
|
||||
let typed = siv.find_id::<views::EditView>("confirm").unwrap().get_content();
|
||||
if decode_size(typed.as_str()).ok() == Some(to_delete) {
|
||||
siv.pop_layer(); // deletion confirmation dialog
|
||||
|
||||
let mut zero_limits = Vec::new();
|
||||
let mut zero_limits = BTreeMap::new();
|
||||
{
|
||||
let l = db.lock();
|
||||
for (&stream_id, stream) in l.streams_by_id() {
|
||||
if stream.camera_id == id {
|
||||
zero_limits.push(dir::NewLimit {
|
||||
let dir_id = match stream.sample_file_dir_id {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
let l = zero_limits.entry(dir_id).or_insert_with(|| Vec::with_capacity(2));
|
||||
l.push(dir::NewLimit {
|
||||
stream_id,
|
||||
limit: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = dir::lower_retention(dir.clone(), &zero_limits) {
|
||||
if let Err(e) = lower_retention(db, zero_limits) {
|
||||
siv.add_layer(views::Dialog::text(format!("Unable to delete recordings: {}", e))
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"));
|
||||
return;
|
||||
}
|
||||
actually_delete(siv, db, dir, id);
|
||||
actually_delete(siv, db, id);
|
||||
} else {
|
||||
siv.add_layer(views::Dialog::text("Please confirm amount.")
|
||||
.title("Try again")
|
||||
@@ -171,8 +205,16 @@ fn confirm_deletion(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::Sa
|
||||
}
|
||||
}
|
||||
|
||||
fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>,
|
||||
id: i32) {
|
||||
fn lower_retention(db: &Arc<db::Database>, zero_limits: BTreeMap<i32, Vec<dir::NewLimit>>)
|
||||
-> Result<(), Error> {
|
||||
for (dir_id, l) in &zero_limits {
|
||||
let dir = db.lock().sample_file_dirs_by_id().get(dir_id).unwrap().open()?;
|
||||
dir::lower_retention(dir, db.clone(), &l)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
|
||||
siv.pop_layer(); // get rid of the add/edit camera dialog.
|
||||
let result = {
|
||||
let mut l = db.lock();
|
||||
@@ -185,15 +227,14 @@ fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, dir: &Arc<dir::Sam
|
||||
} else {
|
||||
// Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry.
|
||||
siv.pop_layer();
|
||||
add_dialog(db, dir, siv);
|
||||
top_dialog(db, siv);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or updates a camera.
|
||||
/// (The former if `item` is None; the latter otherwise.)
|
||||
fn edit_camera_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &mut Cursive,
|
||||
item: &Option<i32>) {
|
||||
let list = views::ListView::new()
|
||||
fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i32>) {
|
||||
let camera_list = views::ListView::new()
|
||||
.child("id", views::TextView::new(match *item {
|
||||
None => "<new>".to_string(),
|
||||
Some(id) => id.to_string(),
|
||||
@@ -203,94 +244,119 @@ fn edit_camera_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv
|
||||
.child("host", views::EditView::new().with_id("host"))
|
||||
.child("username", views::EditView::new().with_id("username"))
|
||||
.child("password", views::EditView::new().with_id("password"))
|
||||
.child("main_rtsp_path", views::LinearLayout::horizontal()
|
||||
.child(views::EditView::new().with_id("main_rtsp_path").full_width())
|
||||
.child(views::DummyView)
|
||||
.child(views::Button::new("Test", |siv| {
|
||||
let c = get_change(siv);
|
||||
press_test(siv, &c, "main", &c.rtsp_paths[0])
|
||||
})))
|
||||
.child("sub_rtsp_path", views::LinearLayout::horizontal()
|
||||
.child(views::EditView::new().with_id("sub_rtsp_path").full_width())
|
||||
.child(views::DummyView)
|
||||
.child(views::Button::new("Test", |siv| {
|
||||
let c = get_change(siv);
|
||||
press_test(siv, &c, "sub", &c.rtsp_paths[1])
|
||||
})))
|
||||
.min_height(8);
|
||||
let layout = views::LinearLayout::vertical()
|
||||
.child(list)
|
||||
.min_height(6);
|
||||
let mut layout = views::LinearLayout::vertical()
|
||||
.child(camera_list)
|
||||
.child(views::TextView::new("description"))
|
||||
.child(views::TextArea::new().with_id("description").min_height(3))
|
||||
.full_width();
|
||||
.child(views::TextArea::new().with_id("description").min_height(3));
|
||||
|
||||
let dirs: Vec<_> = ::std::iter::once(("<none>".to_owned(), None))
|
||||
.chain(db.lock()
|
||||
.sample_file_dirs_by_id()
|
||||
.iter()
|
||||
.map(|(&id, d)| (d.path.as_str().to_owned(), Some(id))))
|
||||
.collect();
|
||||
for &type_ in &db::ALL_STREAM_TYPES {
|
||||
let list = views::ListView::new()
|
||||
.child("rtsp path", views::LinearLayout::horizontal()
|
||||
.child(views::EditView::new()
|
||||
.with_id(format!("{}_rtsp_path", type_.as_str()))
|
||||
.full_width())
|
||||
.child(views::DummyView)
|
||||
.child(views::Button::new("Test", move |siv| press_test(siv, type_))))
|
||||
.child("sample file dir",
|
||||
views::SelectView::<Option<i32>>::new()
|
||||
.with_all(dirs.iter().map(|d| d.clone()))
|
||||
.popup()
|
||||
.with_id(format!("{}_sample_file_dir", type_.as_str())))
|
||||
.child("record", views::Checkbox::new().with_id(format!("{}_record", type_.as_str())))
|
||||
.child("usage/capacity",
|
||||
views::TextView::new("").with_id(format!("{}_usage_cap", type_.as_str())))
|
||||
.min_height(4);
|
||||
layout.add_child(views::DummyView);
|
||||
layout.add_child(views::TextView::new(format!("{} stream", type_.as_str())));
|
||||
layout.add_child(list);
|
||||
}
|
||||
|
||||
let mut dialog = views::Dialog::around(layout);
|
||||
let dialog = if let Some(camera_id) = *item {
|
||||
let l = db.lock();
|
||||
let camera = l.cameras_by_id().get(&camera_id).expect("missing camera");
|
||||
dialog.find_id("uuid", |v: &mut views::TextView| v.set_content(camera.uuid.to_string()))
|
||||
.expect("missing TextView");
|
||||
let mut main_rtsp_path = "";
|
||||
let mut sub_rtsp_path = "";
|
||||
|
||||
let mut bytes = 0;
|
||||
for (_, s) in l.streams_by_id() {
|
||||
if s.camera_id != camera_id { continue; }
|
||||
bytes += s.sample_file_bytes;
|
||||
match s.type_ {
|
||||
db::StreamType::MAIN => main_rtsp_path = &s.rtsp_path,
|
||||
db::StreamType::SUB => sub_rtsp_path = &s.rtsp_path,
|
||||
};
|
||||
for (i, sid) in camera.streams.iter().enumerate() {
|
||||
let t = db::StreamType::from_index(i).unwrap();
|
||||
|
||||
// Find the index into dirs of the stored sample file dir.
|
||||
let mut selected_dir = 0;
|
||||
if let Some(s) = sid.map(|sid| l.streams_by_id().get(&sid).unwrap()) {
|
||||
if let Some(id) = s.sample_file_dir_id {
|
||||
for (i, &(_, d_id)) in dirs.iter().skip(1).enumerate() {
|
||||
if Some(id) == d_id {
|
||||
selected_dir = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes += s.sample_file_bytes;
|
||||
let u = if s.retain_bytes == 0 {
|
||||
"0 / 0 (0.0%)".to_owned()
|
||||
} else {
|
||||
format!("{} / {} ({:.1}%)", s.sample_file_bytes, s.retain_bytes,
|
||||
100. * s.sample_file_bytes as f32 / s.retain_bytes as f32)
|
||||
};
|
||||
dialog.find_id(&format!("{}_rtsp_path", t.as_str()),
|
||||
|v: &mut views::EditView| v.set_content(s.rtsp_path.to_owned()));
|
||||
dialog.find_id(&format!("{}_usage_cap", t.as_str()),
|
||||
|v: &mut views::TextView| v.set_content(u));
|
||||
dialog.find_id(&format!("{}_record", t.as_str()),
|
||||
|v: &mut views::Checkbox| v.set_checked(s.record));
|
||||
}
|
||||
dialog.find_id(&format!("{}_sample_file_dir", t.as_str()),
|
||||
|v: &mut views::SelectView<Option<i32>>| v.set_selection(selected_dir));
|
||||
}
|
||||
let name = camera.short_name.clone();
|
||||
for &(view_id, content) in &[("short_name", &*camera.short_name),
|
||||
("host", &*camera.host),
|
||||
("username", &*camera.username),
|
||||
("password", &*camera.password),
|
||||
("main_rtsp_path", main_rtsp_path),
|
||||
("sub_rtsp_path", sub_rtsp_path)] {
|
||||
("password", &*camera.password)] {
|
||||
dialog.find_id(view_id, |v: &mut views::EditView| v.set_content(content.to_string()))
|
||||
.expect("missing EditView");
|
||||
}
|
||||
for s in l.streams_by_id().values() {
|
||||
if s.camera_id != camera_id { continue };
|
||||
let id = match s.type_ {
|
||||
db::StreamType::MAIN => "main_rtsp_path",
|
||||
db::StreamType::SUB => "sub_rtsp_path",
|
||||
};
|
||||
dialog.find_id(id, |v: &mut views::EditView| v.set_content(s.rtsp_path.to_string()))
|
||||
.expect("missing EditView");
|
||||
}
|
||||
dialog.find_id("description",
|
||||
|v: &mut views::TextArea| v.set_content(camera.description.to_string()))
|
||||
.expect("missing TextArea");
|
||||
dialog.title("Edit camera")
|
||||
.button("Edit", {
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |s| press_edit(s, &db, &dir, Some(camera_id))
|
||||
move |s| press_edit(s, &db, Some(camera_id))
|
||||
})
|
||||
.button("Delete", {
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |s| press_delete(s, &db, &dir, camera_id, name.clone(), bytes)
|
||||
move |s| press_delete(s, &db, camera_id, name.clone(), bytes)
|
||||
})
|
||||
} else {
|
||||
for t in &db::ALL_STREAM_TYPES {
|
||||
dialog.find_id(&format!("{}_usage_cap", t.as_str()),
|
||||
|v: &mut views::TextView| v.set_content("<new>"));
|
||||
}
|
||||
dialog.title("Add camera")
|
||||
.button("Add", {
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |s| press_edit(s, &db, &dir, None)
|
||||
move |s| press_edit(s, &db, None)
|
||||
})
|
||||
};
|
||||
siv.add_layer(dialog.dismiss_button("Cancel"));
|
||||
}
|
||||
|
||||
pub fn add_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &mut Cursive) {
|
||||
pub fn top_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
||||
siv.add_layer(views::Dialog::around(
|
||||
views::SelectView::new()
|
||||
.on_submit({
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |siv, item| edit_camera_dialog(&db, &dir, siv, item)
|
||||
move |siv, item| edit_camera_dialog(&db, siv, item)
|
||||
})
|
||||
.item("<new camera>".to_string(), None)
|
||||
.with_all(db.lock()
|
||||
|
||||
@@ -51,7 +51,7 @@ struct Stream {
|
||||
|
||||
struct Model {
|
||||
db: Arc<db::Database>,
|
||||
dir: Arc<dir::SampleFileDir>,
|
||||
dir_id: i32,
|
||||
fs_capacity: i64,
|
||||
total_used: i64,
|
||||
total_retain: i64,
|
||||
@@ -106,9 +106,9 @@ fn edit_limit(model: &RefCell<Model>, siv: &mut Cursive, id: i32, content: &str)
|
||||
.set_content(if new_value.is_none() { "*" } else { " " });
|
||||
}
|
||||
stream.retain = new_value;
|
||||
info!("model.errors = {}", model.errors);
|
||||
debug!("model.errors = {}", model.errors);
|
||||
if (model.errors == 0) != (old_errors == 0) {
|
||||
info!("toggling change state: errors={}", model.errors);
|
||||
trace!("toggling change state: errors={}", model.errors);
|
||||
siv.find_id::<views::Button>("change")
|
||||
.unwrap()
|
||||
.set_enabled(model.errors == 0);
|
||||
@@ -144,7 +144,11 @@ fn actually_delete(model: &RefCell<Model>, siv: &mut Cursive) {
|
||||
.collect();
|
||||
siv.pop_layer(); // deletion confirmation
|
||||
siv.pop_layer(); // retention dialog
|
||||
if let Err(e) = dir::lower_retention(model.dir.clone(), &new_limits[..]) {
|
||||
let dir = {
|
||||
let l = model.db.lock();
|
||||
l.sample_file_dirs_by_id().get(&model.dir_id).unwrap().open().unwrap()
|
||||
};
|
||||
if let Err(e) = dir::lower_retention(dir, model.db.clone(), &new_limits[..]) {
|
||||
siv.add_layer(views::Dialog::text(format!("Unable to delete excess video: {}", e))
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"));
|
||||
@@ -179,20 +183,110 @@ fn press_change(model: &Rc<RefCell<Model>>, siv: &mut Cursive) {
|
||||
.title("Confirm deletion");
|
||||
siv.add_layer(dialog);
|
||||
} else {
|
||||
siv.screen_mut().pop_layer();
|
||||
siv.pop_layer();
|
||||
update_limits(&model.borrow(), siv);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &mut Cursive) {
|
||||
pub fn top_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
||||
siv.add_layer(views::Dialog::around(
|
||||
views::SelectView::new()
|
||||
.on_submit({
|
||||
let db = db.clone();
|
||||
move |siv, item| match *item {
|
||||
Some(d) => edit_dir_dialog(&db, siv, d),
|
||||
None => add_dir_dialog(&db, siv),
|
||||
}
|
||||
})
|
||||
.item("<new sample file dir>".to_string(), None)
|
||||
.with_all(db.lock()
|
||||
.sample_file_dirs_by_id()
|
||||
.iter()
|
||||
.map(|(&id, d)| (d.path.to_string(), Some(id))))
|
||||
.full_width())
|
||||
.dismiss_button("Done")
|
||||
.title("Edit sample file directories"));
|
||||
}
|
||||
|
||||
fn add_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
||||
siv.add_layer(
|
||||
views::Dialog::around(
|
||||
views::LinearLayout::vertical()
|
||||
.child(views::TextView::new("path"))
|
||||
.child(views::EditView::new()
|
||||
.on_submit({
|
||||
let db = db.clone();
|
||||
move |siv, path| add_dir(&db, siv, path)
|
||||
})
|
||||
.with_id("path")
|
||||
.fixed_width(60)))
|
||||
.button("Add", {
|
||||
let db = db.clone();
|
||||
move |siv| {
|
||||
let path = siv.find_id::<views::EditView>("path").unwrap().get_content();
|
||||
add_dir(&db, siv, &path)
|
||||
}
|
||||
})
|
||||
.button("Cancel", |siv| siv.pop_layer())
|
||||
.title("Add sample file directory"));
|
||||
}
|
||||
|
||||
fn add_dir(db: &Arc<db::Database>, siv: &mut Cursive, path: &str) {
|
||||
if let Err(e) = db.lock().add_sample_file_dir(path.to_owned()) {
|
||||
siv.add_layer(views::Dialog::text(format!("Unable to add path {}: {}", path, e))
|
||||
.dismiss_button("Back")
|
||||
.title("Error"));
|
||||
return;
|
||||
}
|
||||
siv.pop_layer();
|
||||
|
||||
// Recreate the edit dialog from scratch; it's easier than adding the new entry.
|
||||
siv.pop_layer();
|
||||
top_dialog(db, siv);
|
||||
}
|
||||
|
||||
fn delete_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
||||
siv.add_layer(
|
||||
views::Dialog::around(
|
||||
views::TextView::new("Empty (no associated streams)."))
|
||||
.button("Delete", {
|
||||
let db = db.clone();
|
||||
move |siv| {
|
||||
delete_dir(&db, siv, dir_id)
|
||||
}
|
||||
})
|
||||
.button("Cancel", |siv| siv.pop_layer())
|
||||
.title("Delete sample file directory"));
|
||||
}
|
||||
|
||||
fn delete_dir(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
||||
if let Err(e) = db.lock().delete_sample_file_dir(dir_id) {
|
||||
siv.add_layer(views::Dialog::text(format!("Unable to delete dir id {}: {}", dir_id, e))
|
||||
.dismiss_button("Back")
|
||||
.title("Error"));
|
||||
return;
|
||||
}
|
||||
siv.pop_layer();
|
||||
|
||||
// Recreate the edit dialog from scratch; it's easier than adding the new entry.
|
||||
siv.pop_layer();
|
||||
top_dialog(db, siv);
|
||||
}
|
||||
|
||||
fn edit_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
||||
let path;
|
||||
let model = {
|
||||
let mut streams = BTreeMap::new();
|
||||
let mut total_used = 0;
|
||||
let mut total_retain = 0;
|
||||
let fs_capacity;
|
||||
{
|
||||
let db = db.lock();
|
||||
for (&id, s) in db.streams_by_id() {
|
||||
let c = db.cameras_by_id().get(&s.camera_id).expect("stream without camera");
|
||||
let l = db.lock();
|
||||
for (&id, s) in l.streams_by_id() {
|
||||
let c = l.cameras_by_id().get(&s.camera_id).expect("stream without camera");
|
||||
if s.sample_file_dir_id != Some(dir_id) {
|
||||
continue;
|
||||
}
|
||||
streams.insert(id, Stream {
|
||||
label: format!("{}: {}: {}", id, c.short_name, s.type_.as_str()),
|
||||
used: s.sample_file_bytes,
|
||||
@@ -202,11 +296,18 @@ pub fn add_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &m
|
||||
total_used += s.sample_file_bytes;
|
||||
total_retain += s.retain_bytes;
|
||||
}
|
||||
if streams.is_empty() {
|
||||
return delete_dir_dialog(db, siv, dir_id);
|
||||
}
|
||||
let dir = l.sample_file_dirs_by_id().get(&dir_id).unwrap();
|
||||
|
||||
// TODO: go another way if open fails.
|
||||
let stat = dir.open().unwrap().statfs().unwrap();
|
||||
fs_capacity = stat.f_bsize as i64 * stat.f_bavail as i64 + total_used;
|
||||
path = dir.path.clone();
|
||||
}
|
||||
let stat = dir.statfs().unwrap();
|
||||
let fs_capacity = stat.f_bsize as i64 * stat.f_bavail as i64 + total_used;
|
||||
Rc::new(RefCell::new(Model{
|
||||
dir: dir.clone(),
|
||||
Rc::new(RefCell::new(Model {
|
||||
dir_id,
|
||||
db: db.clone(),
|
||||
fs_capacity,
|
||||
total_used,
|
||||
@@ -217,7 +318,7 @@ pub fn add_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &m
|
||||
};
|
||||
|
||||
const RECORD_WIDTH: usize = 8;
|
||||
const BYTES_WIDTH: usize = 20;
|
||||
const BYTES_WIDTH: usize = 22;
|
||||
|
||||
let mut list = views::ListView::new();
|
||||
list.add_child(
|
||||
@@ -276,12 +377,12 @@ pub fn add_dialog(db: &Arc<db::Database>, dir: &Arc<dir::SampleFileDir>, siv: &m
|
||||
.child(views::DummyView.full_width());
|
||||
buttons.add_child(change_button.with_id("change"));
|
||||
buttons.add_child(views::DummyView);
|
||||
buttons.add_child(views::Button::new("Cancel", |siv| siv.screen_mut().pop_layer()));
|
||||
buttons.add_child(views::Button::new("Cancel", |siv| siv.pop_layer()));
|
||||
siv.add_layer(
|
||||
views::Dialog::around(
|
||||
views::LinearLayout::vertical()
|
||||
.child(list)
|
||||
.child(views::DummyView)
|
||||
.child(buttons))
|
||||
.title("Edit retention"));
|
||||
.title(format!("Edit retention for {}", path)));
|
||||
}
|
||||
@@ -38,7 +38,6 @@ extern crate cursive;
|
||||
use self::cursive::Cursive;
|
||||
use self::cursive::views;
|
||||
use db;
|
||||
use dir;
|
||||
use error::Error;
|
||||
use regex::Regex;
|
||||
use std::sync::Arc;
|
||||
@@ -46,7 +45,7 @@ use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
|
||||
mod cameras;
|
||||
mod retention;
|
||||
mod dirs;
|
||||
|
||||
static USAGE: &'static str = r#"
|
||||
Interactive configuration editor.
|
||||
@@ -61,9 +60,6 @@ Options:
|
||||
--db-dir=DIR Set the directory holding the SQLite3 index database.
|
||||
This is typically on a flash device.
|
||||
[default: /var/lib/moonfire-nvr/db]
|
||||
--sample-file-dir=DIR Set the directory holding video data.
|
||||
This is typically on a hard drive.
|
||||
[default: /var/lib/moonfire-nvr/sample]
|
||||
"#;
|
||||
|
||||
static MULTIPLIERS: [(char, u64); 4] = [
|
||||
@@ -123,28 +119,24 @@ fn decode_size(encoded: &str) -> Result<i64, ()> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Args {
|
||||
flag_db_dir: String,
|
||||
flag_sample_file_dir: String,
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), Error> {
|
||||
let args: Args = super::parse_args(USAGE)?;
|
||||
let (_db_dir, conn) = super::open_conn(&args.flag_db_dir, super::OpenMode::ReadWrite)?;
|
||||
let db = Arc::new(db::Database::new(conn)?);
|
||||
//let dir = Arc::new(dir::Fd::open(&args.flag_sample_file_dir)?);
|
||||
let dir = dir::SampleFileDir::new(&args.flag_sample_file_dir, db.clone())?;
|
||||
|
||||
let mut siv = Cursive::new();
|
||||
//siv.add_global_callback('q', |s| s.quit());
|
||||
|
||||
siv.add_layer(views::Dialog::around(
|
||||
views::SelectView::<fn(&Arc<db::Database>, &Arc<dir::SampleFileDir>, &mut Cursive)>::new()
|
||||
views::SelectView::<fn(&Arc<db::Database>, &mut Cursive)>::new()
|
||||
.on_submit({
|
||||
let db = db.clone();
|
||||
let dir = dir.clone();
|
||||
move |siv, item| item(&db, &dir, siv)
|
||||
move |siv, item| item(&db, siv)
|
||||
})
|
||||
.item("Edit cameras".to_string(), cameras::add_dialog)
|
||||
.item("Edit retention".to_string(), retention::add_dialog)
|
||||
.item("Directories and retention".to_string(), dirs::top_dialog)
|
||||
.item("Cameras and streams".to_string(), cameras::top_dialog)
|
||||
)
|
||||
.button("Quit", |siv| siv.quit())
|
||||
.title("Main menu"));
|
||||
|
||||
@@ -75,7 +75,7 @@ enum OpenMode {
|
||||
/// Locks and opens the database.
|
||||
/// The returned `dir::Fd` holds the lock and should be kept open as long as the `Connection` is.
|
||||
fn open_conn(db_dir: &str, mode: OpenMode) -> Result<(dir::Fd, rusqlite::Connection), Error> {
|
||||
let dir = dir::Fd::open(db_dir)?;
|
||||
let dir = dir::Fd::open(db_dir, mode == OpenMode::Create)?;
|
||||
let ro = mode == OpenMode::ReadOnly;
|
||||
dir.lock(if ro { libc::LOCK_SH } else { libc::LOCK_EX } | libc::LOCK_NB)
|
||||
.map_err(|e| Error{description: format!("db dir {:?} already in use; can't get {} lock",
|
||||
|
||||
@@ -32,6 +32,7 @@ use clock;
|
||||
use db;
|
||||
use dir;
|
||||
use error::Error;
|
||||
use fnv::FnvHashMap;
|
||||
use futures::{Future, Stream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -55,9 +56,6 @@ Options:
|
||||
--db-dir=DIR Set the directory holding the SQLite3 index database.
|
||||
This is typically on a flash device.
|
||||
[default: /var/lib/moonfire-nvr/db]
|
||||
--sample-file-dir=DIR Set the directory holding video data.
|
||||
This is typically on a hard drive.
|
||||
[default: /var/lib/moonfire-nvr/sample]
|
||||
--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.
|
||||
@@ -68,7 +66,6 @@ Options:
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Args {
|
||||
flag_db_dir: String,
|
||||
flag_sample_file_dir: String,
|
||||
flag_http_addr: String,
|
||||
flag_ui_dir: String,
|
||||
flag_read_only: bool,
|
||||
@@ -92,48 +89,90 @@ fn resolve_zone() -> String {
|
||||
p[ZONEINFO_PATH.len()..].into()
|
||||
}
|
||||
|
||||
struct Syncer {
|
||||
dir: Arc<dir::SampleFileDir>,
|
||||
channel: dir::SyncerChannel,
|
||||
join: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub fn run() -> Result<(), Error> {
|
||||
let args: Args = super::parse_args(USAGE)?;
|
||||
let (_db_dir, conn) = super::open_conn(
|
||||
&args.flag_db_dir,
|
||||
if args.flag_read_only { super::OpenMode::ReadOnly } else { super::OpenMode::ReadWrite })?;
|
||||
let db = Arc::new(db::Database::new(conn).unwrap());
|
||||
|
||||
// TODO: multiple sample file dirs.
|
||||
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), resolve_zone())?;
|
||||
let s = web::Service::new(db.clone(), Some(&args.flag_ui_dir), resolve_zone())?;
|
||||
|
||||
// Start a streamer for each stream.
|
||||
let shutdown_streamers = Arc::new(AtomicBool::new(false));
|
||||
let mut streamers = Vec::new();
|
||||
let syncer = if !args.flag_read_only {
|
||||
let (syncer_channel, syncer_join) = dir::start_syncer(dir.clone()).unwrap();
|
||||
let syncers = if !args.flag_read_only {
|
||||
let l = db.lock();
|
||||
let mut dirs = FnvHashMap::with_capacity_and_hasher(
|
||||
l.sample_file_dirs_by_id().len(), Default::default());
|
||||
let streams = l.streams_by_id().len();
|
||||
let env = streamer::Environment{
|
||||
let env = streamer::Environment {
|
||||
db: &db,
|
||||
dir: &dir,
|
||||
clocks: &clock::REAL,
|
||||
opener: &*stream::FFMPEG,
|
||||
shutdown: &shutdown_streamers,
|
||||
};
|
||||
|
||||
// Create directories for streams that need them.
|
||||
for stream in l.streams_by_id().values() {
|
||||
if let (Some(id), true) = (stream.sample_file_dir_id, stream.record) {
|
||||
dirs.entry(id).or_insert_with(|| {
|
||||
let d = l.sample_file_dirs_by_id().get(&id).unwrap();
|
||||
info!("Starting syncer for path {}", d.path);
|
||||
d.open()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Then, with the lock dropped, create syncers.
|
||||
drop(l);
|
||||
let mut syncers = FnvHashMap::with_capacity_and_hasher(dirs.len(), Default::default());
|
||||
for (id, dir) in dirs.drain() {
|
||||
let dir = dir?;
|
||||
let (channel, join) = dir::start_syncer(dir.clone(), db.clone())?;
|
||||
syncers.insert(id, Syncer {
|
||||
dir,
|
||||
channel,
|
||||
join,
|
||||
});
|
||||
}
|
||||
|
||||
// Then start up streams.
|
||||
let l = db.lock();
|
||||
for (i, (id, stream)) in l.streams_by_id().iter().enumerate() {
|
||||
if !stream.record {
|
||||
continue;
|
||||
}
|
||||
let camera = l.cameras_by_id().get(&stream.camera_id).unwrap();
|
||||
let sample_file_dir_id = match stream.sample_file_dir_id {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!("Can't record stream {} ({}/{}) because it has no sample file dir",
|
||||
id, camera.short_name, stream.type_.as_str());
|
||||
continue;
|
||||
},
|
||||
};
|
||||
let rotate_offset_sec = streamer::ROTATE_INTERVAL_SEC * i as i64 / streams as i64;
|
||||
let mut streamer = streamer::Streamer::new(&env, syncer_channel.clone(), *id, camera,
|
||||
stream, rotate_offset_sec,
|
||||
let syncer = syncers.get(&sample_file_dir_id).unwrap();
|
||||
let mut streamer = streamer::Streamer::new(&env, syncer.dir.clone(),
|
||||
syncer.channel.clone(), *id, camera, stream,
|
||||
rotate_offset_sec,
|
||||
streamer::ROTATE_INTERVAL_SEC);
|
||||
info!("Starting streamer for {}", streamer.short_name());
|
||||
let name = format!("s-{}", streamer.short_name());
|
||||
streamers.push(thread::Builder::new().name(name).spawn(move|| {
|
||||
streamer.run();
|
||||
}).expect("can't create thread"));
|
||||
}
|
||||
Some((syncer_channel, syncer_join))
|
||||
drop(l);
|
||||
Some(syncers)
|
||||
} else { None };
|
||||
|
||||
// Start the web interface.
|
||||
@@ -153,10 +192,11 @@ pub fn run() -> Result<(), Error> {
|
||||
streamer.join().unwrap();
|
||||
}
|
||||
|
||||
if let Some((syncer_channel, syncer_join)) = syncer {
|
||||
info!("Shutting down syncer.");
|
||||
drop(syncer_channel);
|
||||
syncer_join.join().unwrap();
|
||||
if let Some(mut ss) = syncers {
|
||||
for (_, s) in ss.drain() {
|
||||
drop(s.channel);
|
||||
s.join.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
info!("Exiting.");
|
||||
|
||||
@@ -49,9 +49,8 @@ Options:
|
||||
--db-dir=DIR Set the directory holding the SQLite3 index database.
|
||||
This is typically on a flash device.
|
||||
[default: /var/lib/moonfire-nvr/db]
|
||||
--sample-file-dir=DIR Set the directory holding video data.
|
||||
--sample-file-dir=DIR When upgrading from schema version 1 to 2, the sample file directory.
|
||||
This is typically on a hard drive.
|
||||
[default: /var/lib/moonfire-nvr/sample]
|
||||
--preset-journal=MODE Resets the SQLite journal_mode to the specified mode
|
||||
prior to the upgrade. The default, delete, is
|
||||
recommended. off is very dangerous but may be
|
||||
@@ -65,15 +64,15 @@ Options:
|
||||
const UPGRADE_NOTES: &'static str =
|
||||
concat!("upgraded using moonfire-nvr ", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
const UPGRADERS: [fn(&rusqlite::Transaction) -> Result<(), Error>; 2] = [
|
||||
const UPGRADERS: [fn(&rusqlite::Transaction, &Args) -> Result<(), Error>; 2] = [
|
||||
v0_to_v1::run,
|
||||
v1_to_v2::run,
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Args {
|
||||
pub struct Args {
|
||||
flag_db_dir: String,
|
||||
flag_sample_file_dir: String,
|
||||
flag_sample_file_dir: Option<String>,
|
||||
flag_preset_journal: String,
|
||||
flag_no_vacuum: bool,
|
||||
}
|
||||
@@ -105,7 +104,7 @@ pub fn run() -> Result<(), Error> {
|
||||
for ver in old_ver .. db::EXPECTED_VERSION {
|
||||
info!("...from version {} to version {}", ver, ver + 1);
|
||||
let tx = conn.transaction()?;
|
||||
UPGRADERS[ver as usize](&tx)?;
|
||||
UPGRADERS[ver as usize](&tx, &args)?;
|
||||
tx.execute(r#"
|
||||
insert into version (id, unix_time, notes)
|
||||
values (?, cast(strftime('%s', 'now') as int32), ?)
|
||||
|
||||
@@ -37,7 +37,7 @@ use rusqlite;
|
||||
use std::collections::HashMap;
|
||||
use strutil;
|
||||
|
||||
pub fn run(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
pub fn run(tx: &rusqlite::Transaction, _args: &super::Args) -> Result<(), Error> {
|
||||
// These create statements match the schema.sql when version 1 was the latest.
|
||||
tx.execute_batch(r#"
|
||||
alter table camera rename to old_camera;
|
||||
|
||||
@@ -33,8 +33,32 @@
|
||||
use error::Error;
|
||||
use rusqlite;
|
||||
|
||||
pub fn run(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
pub fn run(tx: &rusqlite::Transaction, args: &super::Args) -> Result<(), Error> {
|
||||
// These create statements match the schema.sql when version 2 was the latest.
|
||||
tx.execute_batch(r#"
|
||||
create table sample_file_dir (
|
||||
id integer primary key,
|
||||
path text unique not null,
|
||||
uuid blob unique not null check (length(uuid) = 16)
|
||||
);
|
||||
"#)?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(r#"
|
||||
insert into sample_file_dir (path, uuid)
|
||||
values (:path, :uuid)
|
||||
"#)?;
|
||||
let uuid = ::uuid::Uuid::new_v4();
|
||||
let uuid_bytes = &uuid.as_bytes()[..];
|
||||
let path = args.flag_sample_file_dir
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::new("--sample-file-dir required when upgrading from
|
||||
schema version 1 to 2.".to_owned()))?;
|
||||
stmt.execute_named(&[
|
||||
(":path", &path.as_str()),
|
||||
(":uuid", &uuid_bytes),
|
||||
])?;
|
||||
}
|
||||
|
||||
tx.execute_batch(r#"
|
||||
alter table camera rename to old_camera;
|
||||
alter table recording rename to old_recording;
|
||||
@@ -54,6 +78,7 @@ pub fn run(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
create table stream (
|
||||
id integer primary key,
|
||||
camera_id integer not null references camera (id),
|
||||
sample_file_dir_id integer references sample_file_dir (id),
|
||||
type text not null check (type in ('main', 'sub')),
|
||||
record integer not null check (record in (1, 0)),
|
||||
rtsp_path text not null,
|
||||
@@ -113,29 +138,32 @@ pub fn run(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
-- Insert main streams using the same id as the camera, to ease changing recordings.
|
||||
insert into stream
|
||||
select
|
||||
id,
|
||||
id,
|
||||
old_camera.id,
|
||||
old_camera.id,
|
||||
sample_file_dir.id,
|
||||
'main',
|
||||
1,
|
||||
main_rtsp_path,
|
||||
retain_bytes,
|
||||
next_recording_id
|
||||
old_camera.main_rtsp_path,
|
||||
old_camera.retain_bytes,
|
||||
old_camera.next_recording_id
|
||||
from
|
||||
old_camera;
|
||||
old_camera cross join sample_file_dir;
|
||||
|
||||
-- Insert sub stream (if path is non-empty) using any id.
|
||||
insert into stream (camera_id, type, record, rtsp_path, retain_bytes, next_recording_id)
|
||||
insert into stream (camera_id, sample_file_dir_id, type, record, rtsp_path, retain_bytes,
|
||||
next_recording_id)
|
||||
select
|
||||
id,
|
||||
old_camera.id,
|
||||
sample_file_dir.id,
|
||||
'sub',
|
||||
0,
|
||||
sub_rtsp_path,
|
||||
old_camera.sub_rtsp_path,
|
||||
0,
|
||||
0
|
||||
from
|
||||
old_camera
|
||||
old_camera cross join sample_file_dir
|
||||
where
|
||||
sub_rtsp_path != '';
|
||||
old_camera.sub_rtsp_path != '';
|
||||
|
||||
insert into recording
|
||||
select
|
||||
|
||||
Reference in New Issue
Block a user