diff --git a/db/db.rs b/db/db.rs index fa5f1df..b4911b3 100644 --- a/db/db.rs +++ b/db/db.rs @@ -348,7 +348,7 @@ pub struct Camera { pub uuid: Uuid, pub short_name: String, pub description: String, - pub host: String, + pub onvif_host: String, pub username: String, pub password: String, pub streams: [Option; 2], @@ -402,7 +402,7 @@ pub struct Stream { pub camera_id: i32, pub sample_file_dir_id: Option, pub type_: StreamType, - pub rtsp_path: String, + pub rtsp_url: String, pub retain_bytes: i64, pub flush_if_sec: i64, @@ -465,7 +465,7 @@ pub struct LiveSegment { #[derive(Clone, Debug, Default)] pub struct StreamChange { pub sample_file_dir_id: Option, - pub rtsp_path: String, + pub rtsp_url: String, pub record: bool, pub flush_if_sec: i64, } @@ -475,7 +475,7 @@ pub struct StreamChange { pub struct CameraChange { pub short_name: String, pub description: String, - pub host: String, + pub onvif_host: String, pub username: String, pub password: String, @@ -678,7 +678,7 @@ impl StreamStateChanger { d, sc.sample_file_dir_id, sid); } } - if !have_data && sc.rtsp_path.is_empty() && sc.sample_file_dir_id.is_none() && + if !have_data && sc.rtsp_url.is_empty() && sc.sample_file_dir_id.is_none() && !sc.record { // Delete stream. let mut stmt = tx.prepare_cached(r#" @@ -692,7 +692,7 @@ impl StreamStateChanger { // Update stream. let mut stmt = tx.prepare_cached(r#" update stream set - rtsp_path = :rtsp_path, + rtsp_url = :rtsp_url, record = :record, flush_if_sec = :flush_if_sec, sample_file_dir_id = :sample_file_dir_id @@ -700,7 +700,7 @@ impl StreamStateChanger { id = :id "#)?; let rows = stmt.execute_named(&[ - (":rtsp_path", &sc.rtsp_path), + (":rtsp_url", &sc.rtsp_url), (":record", &sc.record), (":flush_if_sec", &sc.flush_if_sec), (":sample_file_dir_id", &sc.sample_file_dir_id), @@ -714,22 +714,22 @@ impl StreamStateChanger { streams.push((sid, Some((camera_id, type_, sc)))); } } else { - if sc.rtsp_path.is_empty() && sc.sample_file_dir_id.is_none() && !sc.record { + if sc.rtsp_url.is_empty() && sc.sample_file_dir_id.is_none() && !sc.record { // Do nothing; there is no record and we want to keep it that way. continue; } // Insert stream. let mut stmt = tx.prepare_cached(r#" - insert into stream (camera_id, sample_file_dir_id, type, rtsp_path, record, + insert into stream (camera_id, sample_file_dir_id, type, rtsp_url, record, retain_bytes, flush_if_sec, next_recording_id) - values (:camera_id, :sample_file_dir_id, :type, :rtsp_path, :record, + values (:camera_id, :sample_file_dir_id, :type, :rtsp_url, :record, 0, :flush_if_sec, 1) "#)?; stmt.execute_named(&[ (":camera_id", &camera_id), (":sample_file_dir_id", &sc.sample_file_dir_id), (":type", &type_.as_str()), - (":rtsp_path", &sc.rtsp_path), + (":rtsp_url", &sc.rtsp_url), (":record", &sc.record), (":flush_if_sec", &sc.flush_if_sec), ])?; @@ -757,7 +757,7 @@ impl StreamStateChanger { type_, camera_id, sample_file_dir_id: sc.sample_file_dir_id, - rtsp_path: mem::replace(&mut sc.rtsp_path, String::new()), + rtsp_url: mem::replace(&mut sc.rtsp_url, String::new()), retain_bytes: 0, flush_if_sec: sc.flush_if_sec, range: None, @@ -778,7 +778,7 @@ impl StreamStateChanger { (Entry::Occupied(e), Some((_, _, sc))) => { let e = e.into_mut(); e.sample_file_dir_id = sc.sample_file_dir_id; - e.rtsp_path = sc.rtsp_path; + e.rtsp_url = sc.rtsp_url; e.record = sc.record; e.flush_if_sec = sc.flush_if_sec; }, @@ -1401,7 +1401,7 @@ impl LockedDatabase { uuid, short_name, description, - host, + onvif_host, username, password from @@ -1416,7 +1416,7 @@ impl LockedDatabase { uuid: uuid.0, short_name: row.get(2)?, description: row.get(3)?, - host: row.get(4)?, + onvif_host: row.get(4)?, username: row.get(5)?, password: row.get(6)?, streams: Default::default(), @@ -1437,7 +1437,7 @@ impl LockedDatabase { type, camera_id, sample_file_dir_id, - rtsp_path, + rtsp_url, retain_bytes, flush_if_sec, next_recording_id, @@ -1463,7 +1463,7 @@ impl LockedDatabase { type_, camera_id, sample_file_dir_id: row.get(3)?, - rtsp_path: row.get(4)?, + rtsp_url: row.get(4)?, retain_bytes: row.get(5)?, flush_if_sec, range: None, @@ -1618,14 +1618,16 @@ impl LockedDatabase { let camera_id; { let mut stmt = tx.prepare_cached(r#" - insert into camera (uuid, short_name, description, host, username, password) - values (:uuid, :short_name, :description, :host, :username, :password) + insert into camera (uuid, short_name, description, onvif_host, username, + password) + values (:uuid, :short_name, :description, :onvif_host, :username, + :password) "#)?; stmt.execute_named(&[ (":uuid", &uuid_bytes), (":short_name", &camera.short_name), (":description", &camera.description), - (":host", &camera.host), + (":onvif_host", &camera.onvif_host), (":username", &camera.username), (":password", &camera.password), ])?; @@ -1640,7 +1642,7 @@ impl LockedDatabase { uuid, short_name: camera.short_name, description: camera.description, - host: camera.host, + onvif_host: camera.onvif_host, username: camera.username, password: camera.password, streams, @@ -1664,7 +1666,7 @@ impl LockedDatabase { update camera set short_name = :short_name, description = :description, - host = :host, + onvif_host = :onvif_host, username = :username, password = :password where @@ -1674,7 +1676,7 @@ impl LockedDatabase { (":id", &camera_id), (":short_name", &camera.short_name), (":description", &camera.description), - (":host", &camera.host), + (":onvif_host", &camera.onvif_host), (":username", &camera.username), (":password", &camera.password), ])?; @@ -1685,7 +1687,7 @@ impl LockedDatabase { tx.commit()?; c.short_name = camera.short_name; c.description = camera.description; - c.host = camera.host; + c.onvif_host = camera.onvif_host; c.username = camera.username; c.password = camera.password; c.streams = streams.apply(&mut self.streams_by_id); @@ -2036,11 +2038,11 @@ mod tests { rows += 1; camera_id = row.id; assert_eq!(uuid, row.uuid); - assert_eq!("test-camera", row.host); + assert_eq!("test-camera", row.onvif_host); assert_eq!("foo", row.username); assert_eq!("bar", row.password); - //assert_eq!("/main", row.main_rtsp_path); - //assert_eq!("/sub", row.sub_rtsp_path); + //assert_eq!("/main", row.main_rtsp_url); + //assert_eq!("/sub", row.sub_rtsp_url); //assert_eq!(42, row.retain_bytes); //assert_eq!(None, row.range); //assert_eq!(recording::Duration(0), row.duration); @@ -2225,19 +2227,19 @@ mod tests { let mut c = CameraChange { short_name: "testcam".to_owned(), description: "".to_owned(), - host: "test-camera".to_owned(), + onvif_host: "test-camera".to_owned(), username: "foo".to_owned(), password: "bar".to_owned(), streams: [ StreamChange { sample_file_dir_id: Some(sample_file_dir_id), - rtsp_path: "/main".to_owned(), + rtsp_url: "rtsp://test-camera/main".to_owned(), record: false, flush_if_sec: 1, }, StreamChange { sample_file_dir_id: Some(sample_file_dir_id), - rtsp_path: "/sub".to_owned(), + rtsp_url: "rtsp://test-camera/sub".to_owned(), record: true, flush_if_sec: 1, }, diff --git a/db/schema.sql b/db/schema.sql index 263e1e1..19546de 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -94,8 +94,11 @@ create table camera ( -- A short description of the camera. description text, - -- The host (or IP address) to use in rtsp:// URLs when accessing the camera. - host text, + -- The host part of the http:// URL when accessing ONVIF, optionally + -- including ":". Eg with ONVIF host "192.168.1.110:85", the full URL + -- of the devie management service will be + -- "http://192.168.1.110:85/device_service". + onvif_host text, -- The username to use when accessing the camera. -- If empty, no username or password will be supplied. @@ -116,8 +119,9 @@ create table stream ( -- will not be deleted. record integer not null check (record in (1, 0)), - -- The path (starting with "/") to use in rtsp:// URLs to for this stream. - rtsp_path text not null, + -- The rtsp:// URL to use for this stream, excluding username and password. + -- (Those are taken from the camera row's respective fields.) + rtsp_url text not null, -- The number of bytes of video to retain, excluding the currently-recording -- file. Older files will be deleted as necessary to stay within this limit. diff --git a/db/testutil.rs b/db/testutil.rs index 6021c53..b55bfa7 100644 --- a/db/testutil.rs +++ b/db/testutil.rs @@ -96,13 +96,13 @@ impl TestDb { assert_eq!(TEST_CAMERA_ID, l.add_camera(db::CameraChange { short_name: "test camera".to_owned(), description: "".to_owned(), - host: "test-camera".to_owned(), + onvif_host: "test-camera".to_owned(), username: "foo".to_owned(), password: "bar".to_owned(), streams: [ db::StreamChange { sample_file_dir_id: Some(sample_file_dir_id), - rtsp_path: "/main".to_owned(), + rtsp_url: "rtsp://test-camera/main".to_owned(), record: true, flush_if_sec, }, diff --git a/db/upgrade/v3_to_v4.rs b/db/upgrade/v3_to_v4.rs index 6d4ad0d..ad0fbe3 100644 --- a/db/upgrade/v3_to_v4.rs +++ b/db/upgrade/v3_to_v4.rs @@ -70,6 +70,57 @@ pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> -- behavior. Newly created users won't have prepopulated permissions like this. update user set permissions = X'0801'; update user_session set permissions = X'0801'; + + alter table stream rename to old_stream; + 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_url text not null, + retain_bytes integer not null check (retain_bytes >= 0), + flush_if_sec integer not null, + next_recording_id integer not null check (next_recording_id >= 0), + unique (camera_id, type) + ); + insert into stream + select + s.id, + s.camera_id, + s.sample_file_dir_id, + s.type, + s.record, + 'rtsp://' || c.host || s.rtsp_path as rtsp_url, + retain_bytes, + flush_if_sec, + next_recording_id + from + old_stream s join camera c on (s.camera_id = c.id); + drop table old_stream; + + alter table camera rename to old_camera; + create table camera ( + id integer primary key, + uuid blob unique not null check (length(uuid) = 16), + short_name text not null, + description text, + onvif_host text, + username text, + password text + ); + insert into camera + select + id, + uuid, + short_name, + description, + host, + username, + password + from + old_camera; + drop table old_camera; "#)?; Ok(()) } diff --git a/design/api.md b/design/api.md index eab7d08..dc51a4c 100644 --- a/design/api.md +++ b/design/api.md @@ -76,7 +76,7 @@ The `application/json` response will have a dict as follows: a dictionary describing the configuration of the camera: * `username` * `password` - * `host` + * `onvif_host` * `streams`: a dict of stream type ("main" or "sub") to a dictionary describing the stream: * `retainBytes`: the configured total number of bytes of completed @@ -136,7 +136,7 @@ Example response: "shortName": "driveway", "description": "Hikvision DS-2CD2032 overlooking the driveway from east", "config": { - "host": "192.168.1.100", + "onvif_host": "192.168.1.100", "user": "admin", "password": "12345", }, diff --git a/src/cmds/config/cameras.rs b/src/cmds/config/cameras.rs index a0203dd..f19ab26 100644 --- a/src/cmds/config/cameras.rs +++ b/src/cmds/config/cameras.rs @@ -38,6 +38,7 @@ use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; use super::{decode_size, encode_size}; +use url::Url; /// Builds a `CameraChange` from an active `edit_camera_dialog`. fn get_change(siv: &mut Cursive) -> db::CameraChange { @@ -45,19 +46,19 @@ fn get_change(siv: &mut Cursive) -> db::CameraChange { // https://github.com/gyscos/Cursive/issues/144 let sn = siv.find_id::("short_name").unwrap().get_content().as_str().into(); let d = siv.find_id::("description").unwrap().get_content().into(); - let h = siv.find_id::("host").unwrap().get_content().as_str().into(); + let h = siv.find_id::("onvif_host").unwrap().get_content().as_str().into(); let u = siv.find_id::("username").unwrap().get_content().as_str().into(); let p = siv.find_id::("password").unwrap().get_content().as_str().into(); let mut c = db::CameraChange { short_name: sn, description: d, - host: h, + onvif_host: h, username: u, password: p, streams: Default::default(), }; for &t in &db::ALL_STREAM_TYPES { - let p = siv.find_id::(&format!("{}_rtsp_path", t.as_str())) + let u = siv.find_id::(&format!("{}_rtsp_url", t.as_str())) .unwrap().get_content().as_str().into(); let r = siv.find_id::(&format!("{}_record", t.as_str())) .unwrap().is_checked(); @@ -68,7 +69,7 @@ fn get_change(siv: &mut Cursive) -> db::CameraChange { &format!("{}_sample_file_dir", t.as_str())) .unwrap().selection().unwrap(); c.streams[t.index()] = db::StreamChange { - rtsp_path: p, + rtsp_url: u, sample_file_dir_id: d, record: r, flush_if_sec: f, @@ -101,10 +102,10 @@ fn press_edit(siv: &mut Cursive, db: &Arc, id: Option) { } } -fn press_test_inner(url: &str) -> Result { +fn press_test_inner(url: &Url) -> Result { let stream = stream::FFMPEG.open(stream::Source::Rtsp { - url, - redacted_url: url, + url: url.as_str(), + redacted_url: url.as_str(), // don't need redaction in config UI. })?; let extra_data = stream.get_extra_data()?; Ok(format!("{}x{} video stream", extra_data.width, extra_data.height)) @@ -112,11 +113,24 @@ fn press_test_inner(url: &str) -> Result { 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); + let mut url = match Url::parse(&c.streams[t.index()].rtsp_url) { + Ok(u) => u, + Err(e) => { + siv.add_layer(views::Dialog::text( + format!("Unparseable URL: {}", e)) + .title("Stream test failed") + .dismiss_button("Back")); + return; + }, + }; + + if !c.username.is_empty() { + url.set_username(&c.username); + url.set_password(Some(&c.password)); + } 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)) + t.as_str(), &url)) .title("Testing")); // Let siv have this thread for its event loop; do the work in a background thread. @@ -132,7 +146,7 @@ fn press_test(siv: &mut Cursive, t: db::StreamType) { let description = match r { Err(ref e) => { siv.add_layer( - views::Dialog::text(format!("{} stream at {}:\n\n{}", t.as_str(), url, e)) + views::Dialog::text(format!("{} stream at {}:\n\n{}", t.as_str(), &url, e)) .title("Stream test failed") .dismiss_button("Back")); return; @@ -140,7 +154,7 @@ fn press_test(siv: &mut Cursive, t: db::StreamType) { Ok(ref d) => d, }; siv.add_layer(views::Dialog::text( - format!("{} stream at {}:\n\n{}", t.as_str(), url, description)) + format!("{} stream at {}:\n\n{}", t.as_str(), &url, description)) .title("Stream test succeeded") .dismiss_button("Back")); })).unwrap(); @@ -247,7 +261,7 @@ fn edit_camera_dialog(db: &Arc, siv: &mut Cursive, item: &Option").with_id("uuid")) .child("short name", views::EditView::new().with_id("short_name")) - .child("host", views::EditView::new().with_id("host")) + .child("onvif_host", views::EditView::new().with_id("onvif_host")) .child("username", views::EditView::new().with_id("username")) .child("password", views::EditView::new().with_id("password")) .min_height(6); @@ -264,9 +278,9 @@ fn edit_camera_dialog(db: &Arc, siv: &mut Cursive, item: &Option, siv: &mut Cursive, item: &Option, siv: &mut Cursive, item: &Option Result<(), Error> { let mut streamer = streamer::Streamer::new(&env, syncer.dir.clone(), syncer.channel.clone(), *id, camera, stream, rotate_offset_sec, - streamer::ROTATE_INTERVAL_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|| { diff --git a/src/json.rs b/src/json.rs index d0975aa..18a1caa 100644 --- a/src/json.rs +++ b/src/json.rs @@ -93,7 +93,7 @@ pub struct Camera<'a> { #[derive(Debug, Serialize)] #[serde(rename_all="camelCase")] pub struct CameraConfig<'a> { - pub host: &'a str, + pub onvif_host: &'a str, pub username: &'a str, pub password: &'a str, } @@ -184,7 +184,7 @@ impl<'a> Camera<'a> { config: match include_config { false => None, true => Some(CameraConfig { - host: &c.host, + onvif_host: &c.onvif_host, username: &c.username, password: &c.password, }), diff --git a/src/streamer.rs b/src/streamer.rs index 297cba8..f630ce5 100644 --- a/src/streamer.rs +++ b/src/streamer.rs @@ -38,6 +38,7 @@ use std::result::Result; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use time; +use url::Url; pub static ROTATE_INTERVAL_SEC: i64 = 60; @@ -60,16 +61,24 @@ pub struct Streamer<'a, C, S> where C: Clocks + Clone, S: 'a + stream::Stream { opener: &'a dyn stream::Opener, stream_id: i32, short_name: String, - url: String, - redacted_url: String, + url: Url, + redacted_url: Url, } impl<'a, C, S> Streamer<'a, C, S> where C: 'a + Clocks + Clone, S: 'a + stream::Stream { pub fn new<'b>(env: &Environment<'a, 'b, C, S>, dir: Arc, syncer_channel: writer::SyncerChannel<::std::fs::File>, stream_id: i32, c: &Camera, s: &Stream, rotate_offset_sec: i64, - rotate_interval_sec: i64) -> Self { - Streamer { + rotate_interval_sec: i64) -> Result { + let mut url = Url::parse(&s.rtsp_url)?; + let mut redacted_url = url.clone(); + if !c.username.is_empty() { + url.set_username(&c.username); + redacted_url.set_username(&c.username); + url.set_password(Some(&c.password)); + redacted_url.set_password(Some("redacted")); + } + Ok(Streamer { shutdown: env.shutdown.clone(), rotate_offset_sec: rotate_offset_sec, rotate_interval_sec: rotate_interval_sec, @@ -79,9 +88,9 @@ impl<'a, C, S> Streamer<'a, C, S> where C: 'a + Clocks + Clone, S: 'a + stream:: opener: env.opener, stream_id: stream_id, short_name: format!("{}-{}", c.short_name, s.type_.as_str()), - url: format!("rtsp://{}:{}@{}{}", c.username, c.password, c.host, s.rtsp_path), - redacted_url: format!("rtsp://{}:redacted@{}{}", c.username, c.host, s.rtsp_path), - } + url, + redacted_url, + }) } pub fn short_name(&self) -> &str { &self.short_name } @@ -104,8 +113,8 @@ impl<'a, C, S> Streamer<'a, C, S> where C: 'a + Clocks + Clone, S: 'a + stream:: let mut stream = { let _t = TimerGuard::new(&clocks, || format!("opening {}", self.redacted_url)); self.opener.open(stream::Source::Rtsp { - url: &self.url, - redacted_url: &self.redacted_url, + url: self.url.as_str(), + redacted_url: self.redacted_url.as_str(), })? }; let realtime_offset = self.db.clocks().realtime() - clocks.monotonic();