mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-04-16 09:08:01 -04:00
json-based config for cameras and streams
for #155 The config interface code for changing cameras is quite messy but seems to work for now.
This commit is contained in:
parent
070400095d
commit
dafd9041d6
1
server/Cargo.lock
generated
1
server/Cargo.lock
generated
@ -2567,6 +2567,7 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"matches",
|
"matches",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -43,7 +43,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"
|
url = { version = "2.1.1", features = ["serde"] }
|
||||||
uuid = { version = "0.8", features = ["std", "v4"] }
|
uuid = { version = "0.8", features = ["std", "v4"] }
|
||||||
itertools = "0.10.0"
|
itertools = "0.10.0"
|
||||||
|
|
||||||
|
269
server/db/db.rs
269
server/db/db.rs
@ -54,7 +54,6 @@ 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.
|
||||||
@ -359,24 +358,25 @@ pub struct Camera {
|
|||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub short_name: String,
|
pub short_name: String,
|
||||||
pub description: String,
|
pub config: crate::json::CameraConfig,
|
||||||
pub onvif_host: String,
|
pub streams: [Option<i32>; NUM_STREAM_TYPES],
|
||||||
pub username: Option<String>,
|
|
||||||
pub password: Option<String>,
|
|
||||||
pub streams: [Option<i32>; 2],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum StreamType {
|
pub enum StreamType {
|
||||||
Main,
|
Main,
|
||||||
Sub,
|
Sub,
|
||||||
|
Ext,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const NUM_STREAM_TYPES: usize = 3;
|
||||||
|
|
||||||
impl StreamType {
|
impl StreamType {
|
||||||
pub fn from_index(i: usize) -> Option<Self> {
|
pub fn from_index(i: usize) -> Option<Self> {
|
||||||
match i {
|
match i {
|
||||||
0 => Some(StreamType::Main),
|
0 => Some(StreamType::Main),
|
||||||
1 => Some(StreamType::Sub),
|
1 => Some(StreamType::Sub),
|
||||||
|
2 => Some(StreamType::Ext),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -385,6 +385,7 @@ impl StreamType {
|
|||||||
match self {
|
match self {
|
||||||
StreamType::Main => 0,
|
StreamType::Main => 0,
|
||||||
StreamType::Sub => 1,
|
StreamType::Sub => 1,
|
||||||
|
StreamType::Ext => 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,6 +393,7 @@ impl StreamType {
|
|||||||
match self {
|
match self {
|
||||||
StreamType::Main => "main",
|
StreamType::Main => "main",
|
||||||
StreamType::Sub => "sub",
|
StreamType::Sub => "sub",
|
||||||
|
StreamType::Ext => "ext",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -399,6 +401,7 @@ impl StreamType {
|
|||||||
match type_ {
|
match type_ {
|
||||||
"main" => Some(StreamType::Main),
|
"main" => Some(StreamType::Main),
|
||||||
"sub" => Some(StreamType::Sub),
|
"sub" => Some(StreamType::Sub),
|
||||||
|
"ext" => Some(StreamType::Ext),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -410,16 +413,15 @@ impl ::std::fmt::Display for StreamType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ALL_STREAM_TYPES: [StreamType; 2] = [StreamType::Main, StreamType::Sub];
|
pub const ALL_STREAM_TYPES: [StreamType; NUM_STREAM_TYPES] =
|
||||||
|
[StreamType::Main, StreamType::Sub, StreamType::Ext];
|
||||||
|
|
||||||
pub struct Stream {
|
pub struct Stream {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub camera_id: i32,
|
pub camera_id: i32,
|
||||||
pub sample_file_dir_id: Option<i32>,
|
pub sample_file_dir_id: Option<i32>,
|
||||||
pub type_: StreamType,
|
pub type_: StreamType,
|
||||||
pub rtsp_url: String,
|
pub config: crate::json::StreamConfig,
|
||||||
pub retain_bytes: i64,
|
|
||||||
pub flush_if_sec: i64,
|
|
||||||
|
|
||||||
/// The time range of recorded data associated with this stream (minimum start time and maximum
|
/// The time range of recorded data associated with this stream (minimum start time and maximum
|
||||||
/// end time). `None` iff there are no recordings for this camera.
|
/// end time). `None` iff there are no recordings for this camera.
|
||||||
@ -455,7 +457,6 @@ pub struct Stream {
|
|||||||
/// Mapping of calendar day (in the server's time zone) to a summary of committed recordings on
|
/// Mapping of calendar day (in the server's time zone) to a summary of committed recordings on
|
||||||
/// that day.
|
/// that day.
|
||||||
pub committed_days: days::Map<days::StreamValue>,
|
pub committed_days: days::Map<days::StreamValue>,
|
||||||
pub record: bool,
|
|
||||||
|
|
||||||
/// The `cum_recordings` currently committed to the database.
|
/// The `cum_recordings` currently committed to the database.
|
||||||
pub(crate) cum_recordings: i32,
|
pub(crate) cum_recordings: i32,
|
||||||
@ -500,24 +501,19 @@ 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: Option<Url>,
|
pub config: crate::json::StreamConfig,
|
||||||
pub record: bool,
|
|
||||||
pub flush_if_sec: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a camera, used by `add_camera` and `update_camera`.
|
/// Information about a camera, used by `add_camera` and `update_camera`.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct CameraChange {
|
pub struct CameraChange {
|
||||||
pub short_name: String,
|
pub short_name: String,
|
||||||
pub description: String,
|
pub config: crate::json::CameraConfig,
|
||||||
pub onvif_host: String,
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub password: Option<String>,
|
|
||||||
|
|
||||||
/// `StreamType t` is represented by `streams[t.index()]`. A default StreamChange will
|
/// `StreamType t` is represented by `streams[t.index()]`. A default StreamChange will
|
||||||
/// correspond to no stream in the database, provided there are no existing recordings for that
|
/// correspond to no stream in the database, provided there are no existing recordings for that
|
||||||
/// stream.
|
/// stream.
|
||||||
pub streams: [StreamChange; 2],
|
pub streams: [StreamChange; NUM_STREAM_TYPES],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stream {
|
impl Stream {
|
||||||
@ -528,7 +524,7 @@ impl Stream {
|
|||||||
None => r.start..r.end,
|
None => r.start..r.end,
|
||||||
});
|
});
|
||||||
self.duration += r.end - r.start;
|
self.duration += r.end - r.start;
|
||||||
self.sample_file_bytes += sample_file_bytes as i64;
|
self.sample_file_bytes += i64::from(sample_file_bytes);
|
||||||
self.fs_bytes += round_up(i64::from(sample_file_bytes));
|
self.fs_bytes += round_up(i64::from(sample_file_bytes));
|
||||||
self.committed_days.adjust(r, 1);
|
self.committed_days.adjust(r, 1);
|
||||||
}
|
}
|
||||||
@ -645,7 +641,7 @@ impl ::std::fmt::Display for CompositeId {
|
|||||||
/// Inserts, updates, or removes streams in the `State` object to match a set of `StreamChange`
|
/// Inserts, updates, or removes streams in the `State` object to match a set of `StreamChange`
|
||||||
/// structs.
|
/// structs.
|
||||||
struct StreamStateChanger {
|
struct StreamStateChanger {
|
||||||
sids: [Option<i32>; 2],
|
sids: [Option<i32>; NUM_STREAM_TYPES],
|
||||||
streams: Vec<(i32, Option<(i32, StreamType, StreamChange)>)>,
|
streams: Vec<(i32, Option<(i32, StreamType, StreamChange)>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -659,8 +655,8 @@ impl StreamStateChanger {
|
|||||||
streams_by_id: &BTreeMap<i32, Stream>,
|
streams_by_id: &BTreeMap<i32, Stream>,
|
||||||
change: &mut CameraChange,
|
change: &mut CameraChange,
|
||||||
) -> Result<Self, Error> {
|
) -> Result<Self, Error> {
|
||||||
let mut sids = [None; 2];
|
let mut sids = [None; NUM_STREAM_TYPES];
|
||||||
let mut streams = Vec::with_capacity(2);
|
let mut streams = Vec::with_capacity(NUM_STREAM_TYPES);
|
||||||
let existing_streams = existing.map(|e| e.streams).unwrap_or_default();
|
let existing_streams = existing.map(|e| e.streams).unwrap_or_default();
|
||||||
for (i, ref mut sc) in change.streams.iter_mut().enumerate() {
|
for (i, ref mut sc) in change.streams.iter_mut().enumerate() {
|
||||||
let type_ = StreamType::from_index(i).unwrap();
|
let type_ = StreamType::from_index(i).unwrap();
|
||||||
@ -681,11 +677,7 @@ impl StreamStateChanger {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !have_data
|
if !have_data && sc.config.is_empty() && sc.sample_file_dir_id.is_none() {
|
||||||
&& sc.rtsp_url.is_none()
|
|
||||||
&& sc.sample_file_dir_id.is_none()
|
|
||||||
&& !sc.record
|
|
||||||
{
|
|
||||||
// Delete stream.
|
// Delete stream.
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut stmt = tx.prepare_cached(
|
||||||
r#"
|
r#"
|
||||||
@ -701,18 +693,14 @@ impl StreamStateChanger {
|
|||||||
let mut stmt = tx.prepare_cached(
|
let mut stmt = tx.prepare_cached(
|
||||||
r#"
|
r#"
|
||||||
update stream set
|
update stream set
|
||||||
rtsp_url = :rtsp_url,
|
config = :config,
|
||||||
record = :record,
|
|
||||||
flush_if_sec = :flush_if_sec,
|
|
||||||
sample_file_dir_id = :sample_file_dir_id
|
sample_file_dir_id = :sample_file_dir_id
|
||||||
where
|
where
|
||||||
id = :id
|
id = :id
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.execute(named_params! {
|
let rows = stmt.execute(named_params! {
|
||||||
":rtsp_url": &sc.rtsp_url.as_ref().map(Url::as_str),
|
":config": &sc.config,
|
||||||
":record": sc.record,
|
|
||||||
":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,
|
||||||
":id": sid,
|
":id": sid,
|
||||||
})?;
|
})?;
|
||||||
@ -724,28 +712,24 @@ 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_none() && sc.sample_file_dir_id.is_none() && !sc.record {
|
if sc.config.is_empty() && sc.sample_file_dir_id.is_none() {
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
// Insert stream.
|
// Insert stream.
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut stmt = tx.prepare_cached(
|
||||||
r#"
|
r#"
|
||||||
insert into stream (camera_id, sample_file_dir_id, type, rtsp_url, record,
|
insert into stream (camera_id, sample_file_dir_id, type, config,
|
||||||
retain_bytes, flush_if_sec, cum_recordings,
|
cum_recordings, cum_media_duration_90k, cum_runs)
|
||||||
cum_media_duration_90k, cum_runs)
|
values (:camera_id, :sample_file_dir_id, :type, :config,
|
||||||
values (:camera_id, :sample_file_dir_id, :type, :rtsp_url, :record,
|
0, 0, 0)
|
||||||
0, :flush_if_sec, 0,
|
|
||||||
0, 0)
|
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
stmt.execute(named_params! {
|
stmt.execute(named_params! {
|
||||||
":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.as_ref().map(Url::as_str),
|
":config": &sc.config,
|
||||||
":record": sc.record,
|
|
||||||
":flush_if_sec": sc.flush_if_sec,
|
|
||||||
})?;
|
})?;
|
||||||
let id = tx.last_insert_rowid() as i32;
|
let id = tx.last_insert_rowid() as i32;
|
||||||
sids[i] = Some(id);
|
sids[i] = Some(id);
|
||||||
@ -758,19 +742,20 @@ impl StreamStateChanger {
|
|||||||
|
|
||||||
/// Applies the change to the given `streams_by_id`. The caller is expected to set
|
/// Applies the change to the given `streams_by_id`. The caller is expected to set
|
||||||
/// `Camera::streams` to the return value.
|
/// `Camera::streams` to the return value.
|
||||||
fn apply(mut self, streams_by_id: &mut BTreeMap<i32, Stream>) -> [Option<i32>; 2] {
|
fn apply(
|
||||||
|
mut self,
|
||||||
|
streams_by_id: &mut BTreeMap<i32, Stream>,
|
||||||
|
) -> [Option<i32>; NUM_STREAM_TYPES] {
|
||||||
for (id, stream) in self.streams.drain(..) {
|
for (id, stream) in self.streams.drain(..) {
|
||||||
use ::std::collections::btree_map::Entry;
|
use ::std::collections::btree_map::Entry;
|
||||||
match (streams_by_id.entry(id), stream) {
|
match (streams_by_id.entry(id), stream) {
|
||||||
(Entry::Vacant(e), Some((camera_id, type_, mut sc))) => {
|
(Entry::Vacant(e), Some((camera_id, type_, sc))) => {
|
||||||
e.insert(Stream {
|
e.insert(Stream {
|
||||||
id,
|
id,
|
||||||
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: sc.rtsp_url.take().map(String::from).unwrap_or_default(),
|
config: sc.config,
|
||||||
retain_bytes: 0,
|
|
||||||
flush_if_sec: sc.flush_if_sec,
|
|
||||||
range: None,
|
range: None,
|
||||||
sample_file_bytes: 0,
|
sample_file_bytes: 0,
|
||||||
fs_bytes: 0,
|
fs_bytes: 0,
|
||||||
@ -781,7 +766,6 @@ impl StreamStateChanger {
|
|||||||
fs_bytes_to_add: 0,
|
fs_bytes_to_add: 0,
|
||||||
duration: recording::Duration(0),
|
duration: recording::Duration(0),
|
||||||
committed_days: days::Map::default(),
|
committed_days: days::Map::default(),
|
||||||
record: sc.record,
|
|
||||||
cum_recordings: 0,
|
cum_recordings: 0,
|
||||||
cum_media_duration: recording::Duration(0),
|
cum_media_duration: recording::Duration(0),
|
||||||
cum_runs: 0,
|
cum_runs: 0,
|
||||||
@ -791,12 +775,10 @@ impl StreamStateChanger {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
(Entry::Vacant(_), None) => {}
|
(Entry::Vacant(_), None) => {}
|
||||||
(Entry::Occupied(e), Some((_, _, mut sc))) => {
|
(Entry::Occupied(e), Some((_, _, 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.take().map(String::from).unwrap_or_default();
|
e.config = sc.config;
|
||||||
e.record = sc.record;
|
|
||||||
e.flush_if_sec = sc.flush_if_sec;
|
|
||||||
}
|
}
|
||||||
(Entry::Occupied(e), None) => {
|
(Entry::Occupied(e), None) => {
|
||||||
e.remove();
|
e.remove();
|
||||||
@ -1596,10 +1578,7 @@ impl LockedDatabase {
|
|||||||
id,
|
id,
|
||||||
uuid,
|
uuid,
|
||||||
short_name,
|
short_name,
|
||||||
description,
|
config
|
||||||
onvif_host,
|
|
||||||
username,
|
|
||||||
password
|
|
||||||
from
|
from
|
||||||
camera;
|
camera;
|
||||||
"#,
|
"#,
|
||||||
@ -1614,10 +1593,7 @@ impl LockedDatabase {
|
|||||||
id,
|
id,
|
||||||
uuid: uuid.0,
|
uuid: uuid.0,
|
||||||
short_name: row.get(2)?,
|
short_name: row.get(2)?,
|
||||||
description: row.get(3)?,
|
config: row.get(3)?,
|
||||||
onvif_host: row.get(4)?,
|
|
||||||
username: row.get(5)?,
|
|
||||||
password: row.get(6)?,
|
|
||||||
streams: Default::default(),
|
streams: Default::default(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -1638,13 +1614,10 @@ impl LockedDatabase {
|
|||||||
type,
|
type,
|
||||||
camera_id,
|
camera_id,
|
||||||
sample_file_dir_id,
|
sample_file_dir_id,
|
||||||
rtsp_url,
|
config,
|
||||||
retain_bytes,
|
|
||||||
flush_if_sec,
|
|
||||||
cum_recordings,
|
cum_recordings,
|
||||||
cum_media_duration_90k,
|
cum_media_duration_90k,
|
||||||
cum_runs,
|
cum_runs
|
||||||
record
|
|
||||||
from
|
from
|
||||||
stream;
|
stream;
|
||||||
"#,
|
"#,
|
||||||
@ -1660,7 +1633,6 @@ impl LockedDatabase {
|
|||||||
.cameras_by_id
|
.cameras_by_id
|
||||||
.get_mut(&camera_id)
|
.get_mut(&camera_id)
|
||||||
.ok_or_else(|| format_err!("missing camera {} for stream {}", camera_id, id))?;
|
.ok_or_else(|| format_err!("missing camera {} for stream {}", camera_id, id))?;
|
||||||
let flush_if_sec = row.get(6)?;
|
|
||||||
self.streams_by_id.insert(
|
self.streams_by_id.insert(
|
||||||
id,
|
id,
|
||||||
Stream {
|
Stream {
|
||||||
@ -1668,9 +1640,7 @@ impl LockedDatabase {
|
|||||||
type_,
|
type_,
|
||||||
camera_id,
|
camera_id,
|
||||||
sample_file_dir_id: row.get(3)?,
|
sample_file_dir_id: row.get(3)?,
|
||||||
rtsp_url: row.get(4)?,
|
config: row.get(4)?,
|
||||||
retain_bytes: row.get(5)?,
|
|
||||||
flush_if_sec,
|
|
||||||
range: None,
|
range: None,
|
||||||
sample_file_bytes: 0,
|
sample_file_bytes: 0,
|
||||||
fs_bytes: 0,
|
fs_bytes: 0,
|
||||||
@ -1681,10 +1651,9 @@ impl LockedDatabase {
|
|||||||
fs_bytes_to_add: 0,
|
fs_bytes_to_add: 0,
|
||||||
duration: recording::Duration(0),
|
duration: recording::Duration(0),
|
||||||
committed_days: days::Map::default(),
|
committed_days: days::Map::default(),
|
||||||
cum_recordings: row.get(7)?,
|
cum_recordings: row.get(5)?,
|
||||||
cum_media_duration: recording::Duration(row.get(8)?),
|
cum_media_duration: recording::Duration(row.get(6)?),
|
||||||
cum_runs: row.get(9)?,
|
cum_runs: row.get(7)?,
|
||||||
record: row.get(10)?,
|
|
||||||
uncommitted: VecDeque::new(),
|
uncommitted: VecDeque::new(),
|
||||||
synced_recordings: 0,
|
synced_recordings: 0,
|
||||||
on_live_segment: Vec::new(),
|
on_live_segment: Vec::new(),
|
||||||
@ -1854,19 +1823,14 @@ impl LockedDatabase {
|
|||||||
{
|
{
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut stmt = tx.prepare_cached(
|
||||||
r#"
|
r#"
|
||||||
insert into camera (uuid, short_name, description, onvif_host, username,
|
insert into camera (uuid, short_name, config)
|
||||||
password)
|
values (:uuid, :short_name, :config)
|
||||||
values (:uuid, :short_name, :description, :onvif_host, :username,
|
|
||||||
:password)
|
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
stmt.execute(named_params! {
|
stmt.execute(named_params! {
|
||||||
":uuid": uuid_bytes,
|
":uuid": uuid_bytes,
|
||||||
":short_name": &camera.short_name,
|
":short_name": &camera.short_name,
|
||||||
":description": &camera.description,
|
":config": &camera.config,
|
||||||
":onvif_host": &camera.onvif_host,
|
|
||||||
":username": &camera.username,
|
|
||||||
":password": &camera.password,
|
|
||||||
})?;
|
})?;
|
||||||
camera_id = tx.last_insert_rowid() as i32;
|
camera_id = tx.last_insert_rowid() as i32;
|
||||||
streams =
|
streams =
|
||||||
@ -1880,10 +1844,7 @@ impl LockedDatabase {
|
|||||||
id: camera_id,
|
id: camera_id,
|
||||||
uuid,
|
uuid,
|
||||||
short_name: camera.short_name,
|
short_name: camera.short_name,
|
||||||
description: camera.description,
|
config: camera.config,
|
||||||
onvif_host: camera.onvif_host,
|
|
||||||
username: camera.username,
|
|
||||||
password: camera.password,
|
|
||||||
streams,
|
streams,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -1891,6 +1852,36 @@ impl LockedDatabase {
|
|||||||
Ok(camera_id)
|
Ok(camera_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a `CameraChange` for the given camera which does nothing.
|
||||||
|
///
|
||||||
|
/// The caller can modify it to taste then pass it to `update_camera`.
|
||||||
|
/// TODO: consider renaming this to `update_camera` and creating a bulk
|
||||||
|
/// `apply_camera_changes`.
|
||||||
|
pub fn null_camera_change(&mut self, camera_id: i32) -> Result<CameraChange, Error> {
|
||||||
|
let camera = self
|
||||||
|
.cameras_by_id
|
||||||
|
.get(&camera_id)
|
||||||
|
.ok_or_else(|| format_err!("no such camera {}", camera_id))?;
|
||||||
|
let mut change = CameraChange {
|
||||||
|
short_name: camera.short_name.clone(),
|
||||||
|
config: camera.config.clone(),
|
||||||
|
streams: Default::default(),
|
||||||
|
};
|
||||||
|
for i in 0..NUM_STREAM_TYPES {
|
||||||
|
if let Some(stream_id) = camera.streams[i] {
|
||||||
|
let s = self
|
||||||
|
.streams_by_id
|
||||||
|
.get(&stream_id)
|
||||||
|
.expect("cameras reference valid streams");
|
||||||
|
change.streams[i] = StreamChange {
|
||||||
|
sample_file_dir_id: s.sample_file_dir_id,
|
||||||
|
config: s.config.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(change)
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a camera.
|
/// Updates a camera.
|
||||||
pub fn update_camera(&mut self, camera_id: i32, mut camera: CameraChange) -> Result<(), Error> {
|
pub fn update_camera(&mut self, camera_id: i32, mut camera: CameraChange) -> Result<(), Error> {
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
@ -1906,10 +1897,7 @@ impl LockedDatabase {
|
|||||||
r#"
|
r#"
|
||||||
update camera set
|
update camera set
|
||||||
short_name = :short_name,
|
short_name = :short_name,
|
||||||
description = :description,
|
config = :config
|
||||||
onvif_host = :onvif_host,
|
|
||||||
username = :username,
|
|
||||||
password = :password
|
|
||||||
where
|
where
|
||||||
id = :id
|
id = :id
|
||||||
"#,
|
"#,
|
||||||
@ -1917,10 +1905,7 @@ impl LockedDatabase {
|
|||||||
let rows = stmt.execute(named_params! {
|
let rows = stmt.execute(named_params! {
|
||||||
":id": camera_id,
|
":id": camera_id,
|
||||||
":short_name": &camera.short_name,
|
":short_name": &camera.short_name,
|
||||||
":description": &camera.description,
|
":config": &camera.config,
|
||||||
":onvif_host": &camera.onvif_host,
|
|
||||||
":username": &camera.username,
|
|
||||||
":password": &camera.password,
|
|
||||||
})?;
|
})?;
|
||||||
if rows != 1 {
|
if rows != 1 {
|
||||||
bail!("Camera {} missing from database", camera_id);
|
bail!("Camera {} missing from database", camera_id);
|
||||||
@ -1928,16 +1913,14 @@ impl LockedDatabase {
|
|||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
c.short_name = camera.short_name;
|
c.short_name = camera.short_name;
|
||||||
c.description = camera.description;
|
c.config = camera.config;
|
||||||
c.onvif_host = camera.onvif_host;
|
|
||||||
c.username = camera.username;
|
|
||||||
c.password = camera.password;
|
|
||||||
c.streams = streams.apply(&mut self.streams_by_id);
|
c.streams = streams.apply(&mut self.streams_by_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a camera and its streams. The camera must have no recordings.
|
/// Deletes a camera and its streams. The camera must have no recordings.
|
||||||
pub fn delete_camera(&mut self, id: i32) -> Result<(), Error> {
|
pub fn delete_camera(&mut self, id: i32) -> Result<(), Error> {
|
||||||
|
// TODO: also verify there are no uncommitted recordings.
|
||||||
let uuid = self
|
let uuid = self
|
||||||
.cameras_by_id
|
.cameras_by_id
|
||||||
.get(&id)
|
.get(&id)
|
||||||
@ -1975,35 +1958,34 @@ impl LockedDatabase {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: it'd make more sense to have a bulk camera/stream edit API than
|
||||||
|
// this specific one.
|
||||||
pub fn update_retention(&mut self, changes: &[RetentionChange]) -> Result<(), Error> {
|
pub fn update_retention(&mut self, changes: &[RetentionChange]) -> Result<(), Error> {
|
||||||
|
// TODO: should validate there's only one change per id.
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut stmt = tx.prepare_cached(
|
||||||
r#"
|
r#"
|
||||||
update stream
|
update stream
|
||||||
set
|
set
|
||||||
record = :record,
|
config = :config
|
||||||
retain_bytes = :retain
|
|
||||||
where
|
where
|
||||||
id = :id
|
id = :id
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
for c in changes {
|
for c in changes {
|
||||||
if c.new_limit < 0 {
|
let stream = self
|
||||||
bail!(
|
.streams_by_id
|
||||||
"can't set limit for stream {} to {}; must be >= 0",
|
.get(&c.stream_id)
|
||||||
c.stream_id,
|
.ok_or_else(|| format_err!("no such stream id {}", c.stream_id))?;
|
||||||
c.new_limit
|
let mut new_config = stream.config.clone();
|
||||||
);
|
new_config.mode = (if c.new_record { "record" } else { "" }).into();
|
||||||
}
|
new_config.retain_bytes = c.new_limit;
|
||||||
let rows = stmt.execute(named_params! {
|
let rows = stmt.execute(named_params! {
|
||||||
":record": c.new_record,
|
":config": &new_config,
|
||||||
":retain": c.new_limit,
|
|
||||||
":id": c.stream_id,
|
":id": c.stream_id,
|
||||||
})?;
|
})?;
|
||||||
if rows != 1 {
|
assert_eq!(rows, 1, "missing stream {}", c.stream_id);
|
||||||
bail!("no such stream {}", c.stream_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
@ -2012,8 +1994,8 @@ impl LockedDatabase {
|
|||||||
.streams_by_id
|
.streams_by_id
|
||||||
.get_mut(&c.stream_id)
|
.get_mut(&c.stream_id)
|
||||||
.expect("stream in db but not state");
|
.expect("stream in db but not state");
|
||||||
s.record = c.new_record;
|
s.config.mode = (if c.new_record { "record" } else { "" }).into();
|
||||||
s.retain_bytes = c.new_limit;
|
s.config.retain_bytes = c.new_limit;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -2369,6 +2351,7 @@ mod tests {
|
|||||||
use crate::testutil;
|
use crate::testutil;
|
||||||
use base::clock;
|
use base::clock;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn setup_conn() -> Connection {
|
fn setup_conn() -> Connection {
|
||||||
@ -2386,9 +2369,12 @@ mod tests {
|
|||||||
rows += 1;
|
rows += 1;
|
||||||
camera_id = row.id;
|
camera_id = row.id;
|
||||||
assert_eq!(uuid, row.uuid);
|
assert_eq!(uuid, row.uuid);
|
||||||
assert_eq!("test-camera", row.onvif_host);
|
assert_eq!(
|
||||||
assert_eq!(Some("foo"), row.username.as_deref());
|
"http://test-camera/",
|
||||||
assert_eq!(Some("bar"), row.password.as_deref());
|
row.config.onvif_base_url.as_ref().unwrap().as_str()
|
||||||
|
);
|
||||||
|
assert_eq!("foo", &row.config.username);
|
||||||
|
assert_eq!("bar", &row.config.password);
|
||||||
//assert_eq!("/main", row.main_rtsp_url);
|
//assert_eq!("/main", row.main_rtsp_url);
|
||||||
//assert_eq!("/sub", row.sub_rtsp_url);
|
//assert_eq!("/sub", row.sub_rtsp_url);
|
||||||
//assert_eq!(42, row.retain_bytes);
|
//assert_eq!(42, row.retain_bytes);
|
||||||
@ -2537,23 +2523,33 @@ mod tests {
|
|||||||
let sample_file_dir_id = { db.lock() }.add_sample_file_dir(path).unwrap();
|
let sample_file_dir_id = { db.lock() }.add_sample_file_dir(path).unwrap();
|
||||||
let mut c = CameraChange {
|
let mut c = CameraChange {
|
||||||
short_name: "testcam".to_owned(),
|
short_name: "testcam".to_owned(),
|
||||||
|
config: crate::json::CameraConfig {
|
||||||
description: "".to_owned(),
|
description: "".to_owned(),
|
||||||
onvif_host: "test-camera".to_owned(),
|
onvif_base_url: Some(Url::parse("http://test-camera/").unwrap()),
|
||||||
username: Some("foo".to_owned()),
|
username: "foo".to_owned(),
|
||||||
password: Some("bar".to_owned()),
|
password: "bar".to_owned(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
streams: [
|
streams: [
|
||||||
StreamChange {
|
StreamChange {
|
||||||
sample_file_dir_id: Some(sample_file_dir_id),
|
sample_file_dir_id: Some(sample_file_dir_id),
|
||||||
rtsp_url: Some(Url::parse("rtsp://test-camera/main").unwrap()),
|
config: crate::json::StreamConfig {
|
||||||
record: false,
|
url: Some(Url::parse("rtsp://test-camera/main").unwrap()),
|
||||||
|
mode: crate::json::STREAM_MODE_RECORD.to_owned(),
|
||||||
flush_if_sec: 1,
|
flush_if_sec: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
StreamChange {
|
StreamChange {
|
||||||
sample_file_dir_id: Some(sample_file_dir_id),
|
sample_file_dir_id: Some(sample_file_dir_id),
|
||||||
rtsp_url: Some(Url::parse("rtsp://test-camera/sub").unwrap()),
|
config: crate::json::StreamConfig {
|
||||||
record: true,
|
url: Some(Url::parse("rtsp://test-camera/sub").unwrap()),
|
||||||
|
mode: crate::json::STREAM_MODE_RECORD.to_owned(),
|
||||||
flush_if_sec: 1,
|
flush_if_sec: 1,
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
StreamChange::default(),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
let camera_id = db.lock().add_camera(c.clone()).unwrap();
|
let camera_id = db.lock().add_camera(c.clone()).unwrap();
|
||||||
@ -2573,19 +2569,27 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
{
|
{
|
||||||
let main = l.streams_by_id().get(&main_stream_id).unwrap();
|
let main = l.streams_by_id().get(&main_stream_id).unwrap();
|
||||||
assert!(main.record);
|
assert_eq!(main.config.mode, crate::json::STREAM_MODE_RECORD);
|
||||||
assert_eq!(main.retain_bytes, 42);
|
assert_eq!(main.config.retain_bytes, 42);
|
||||||
assert_eq!(main.flush_if_sec, 1);
|
assert_eq!(main.config.flush_if_sec, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
l.streams_by_id().get(&sub_stream_id).unwrap().flush_if_sec,
|
l.streams_by_id()
|
||||||
|
.get(&sub_stream_id)
|
||||||
|
.unwrap()
|
||||||
|
.config
|
||||||
|
.flush_if_sec,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
c.streams[1].flush_if_sec = 2;
|
c.streams[1].config.flush_if_sec = 2;
|
||||||
l.update_camera(camera_id, c).unwrap();
|
l.update_camera(camera_id, c).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
l.streams_by_id().get(&sub_stream_id).unwrap().flush_if_sec,
|
l.streams_by_id()
|
||||||
|
.get(&sub_stream_id)
|
||||||
|
.unwrap()
|
||||||
|
.config
|
||||||
|
.flush_if_sec,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2608,6 +2612,7 @@ mod tests {
|
|||||||
.streams_by_id()
|
.streams_by_id()
|
||||||
.get(&sub_stream_id)
|
.get(&sub_stream_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.config
|
||||||
.flush_if_sec,
|
.flush_if_sec,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
|
128
server/db/json.rs
Normal file
128
server/db/json.rs
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||||
|
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||||
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||||
|
|
||||||
|
//! JSON types for use in the database schema. See references from `schema.sql`.
|
||||||
|
|
||||||
|
use rusqlite::types::{FromSqlError, ValueRef};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
macro_rules! sql {
|
||||||
|
($l:ident) => {
|
||||||
|
impl rusqlite::types::FromSql for $l {
|
||||||
|
fn column_result(value: ValueRef) -> Result<Self, FromSqlError> {
|
||||||
|
match value {
|
||||||
|
ValueRef::Text(t) => {
|
||||||
|
Ok(serde_json::from_slice(t)
|
||||||
|
.map_err(|e| FromSqlError::Other(Box::new(e)))?)
|
||||||
|
}
|
||||||
|
_ => Err(FromSqlError::InvalidType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rusqlite::types::ToSql for $l {
|
||||||
|
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
|
||||||
|
Ok(serde_json::to_string(&self)
|
||||||
|
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CameraConfig {
|
||||||
|
/// A short description of the camera.
|
||||||
|
pub description: String,
|
||||||
|
|
||||||
|
/// The base URL for accessing ONVIF; `device_service` will be joined on
|
||||||
|
/// automatically to form the device management service URL.
|
||||||
|
/// Eg with `onvif_base=http://192.168.1.110:85`, the full
|
||||||
|
/// URL of the devie management service will be
|
||||||
|
/// `http://192.168.1.110:85/device_service`.
|
||||||
|
pub onvif_base_url: Option<Url>,
|
||||||
|
|
||||||
|
/// The username to use when accessing the camera.
|
||||||
|
/// If empty, no username or password will be supplied.
|
||||||
|
pub username: String,
|
||||||
|
|
||||||
|
/// The password to use when accessing the camera.
|
||||||
|
pub password: String,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub unknown: Map<String, Value>,
|
||||||
|
}
|
||||||
|
sql!(CameraConfig);
|
||||||
|
|
||||||
|
impl CameraConfig {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.description.is_empty()
|
||||||
|
&& self.onvif_base_url.is_none()
|
||||||
|
&& self.username.is_empty()
|
||||||
|
&& self.password.is_empty()
|
||||||
|
&& self.unknown.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StreamConfig {
|
||||||
|
/// The mode of operation for this camera on startup.
|
||||||
|
///
|
||||||
|
/// Null means entirely disabled. At present, so does any value other than
|
||||||
|
/// `record`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: String,
|
||||||
|
|
||||||
|
/// The `rtsp://` URL to use for this stream, excluding username and
|
||||||
|
/// password.
|
||||||
|
///
|
||||||
|
/// In the future, this might support additional protocols such as `rtmp://`
|
||||||
|
/// or even a private use URI scheme for the [Baichuan
|
||||||
|
/// protocol](https://github.com/thirtythreeforty/neolink).
|
||||||
|
///
|
||||||
|
/// (Credentials are taken from [`CameraConfig`]'s respective fields.)
|
||||||
|
// TODO: should this really be Option?
|
||||||
|
pub url: Option<Url>,
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[serde(default)]
|
||||||
|
pub retain_bytes: i64,
|
||||||
|
|
||||||
|
/// Flush the database when the first instant of completed recording is this
|
||||||
|
/// many seconds old. A value of 0 means that every completed recording will
|
||||||
|
/// cause an immediate flush. Higher values may allow flushes to be combined,
|
||||||
|
/// reducing SSD write cycles. For example, if all streams have a
|
||||||
|
/// `flush_if_sec` >= *x* sec, there will be:
|
||||||
|
///
|
||||||
|
/// * at most one flush per *x* sec in total
|
||||||
|
/// * at most *x* sec of completed but unflushed recordings per stream.
|
||||||
|
/// * at most *x* completed but unflushed recordings per stream, in the
|
||||||
|
/// worst case where a recording instantly fails, waits the 1-second retry
|
||||||
|
/// delay, then fails again, forever.
|
||||||
|
#[serde(default)]
|
||||||
|
pub flush_if_sec: u32,
|
||||||
|
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub unknown: Map<String, Value>,
|
||||||
|
}
|
||||||
|
sql!(StreamConfig);
|
||||||
|
|
||||||
|
pub const STREAM_MODE_RECORD: &'static str = "record";
|
||||||
|
|
||||||
|
impl StreamConfig {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.mode.is_empty()
|
||||||
|
&& self.url.is_none()
|
||||||
|
&& self.retain_bytes == 0
|
||||||
|
&& self.flush_if_sec == 0
|
||||||
|
&& self.unknown.is_empty()
|
||||||
|
}
|
||||||
|
}
|
@ -21,6 +21,7 @@ pub mod days;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod dir;
|
pub mod dir;
|
||||||
mod fs;
|
mod fs;
|
||||||
|
pub mod json;
|
||||||
mod proto {
|
mod proto {
|
||||||
include!(concat!(env!("OUT_DIR"), "/mod.rs"));
|
include!(concat!(env!("OUT_DIR"), "/mod.rs"));
|
||||||
}
|
}
|
||||||
|
@ -70,54 +70,18 @@ create table camera (
|
|||||||
-- A short name of the camera, used in log messages.
|
-- A short name of the camera, used in log messages.
|
||||||
short_name text not null,
|
short_name text not null,
|
||||||
|
|
||||||
-- A short description of the camera.
|
-- A serialized json.CameraConfig
|
||||||
description text,
|
config text not null
|
||||||
|
|
||||||
-- The host part of the http:// URL when accessing ONVIF, optionally
|
|
||||||
-- including ":<port>". 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.
|
|
||||||
username text,
|
|
||||||
|
|
||||||
-- The password to use when accessing the camera.
|
|
||||||
password text
|
|
||||||
);
|
);
|
||||||
|
|
||||||
create table stream (
|
create table stream (
|
||||||
id integer primary key,
|
id integer primary key,
|
||||||
camera_id integer not null references camera (id),
|
camera_id integer not null references camera (id),
|
||||||
sample_file_dir_id integer references sample_file_dir (id),
|
sample_file_dir_id integer references sample_file_dir (id),
|
||||||
type text not null check (type in ('main', 'sub')),
|
type text not null check (type in ('main', 'sub', 'ext')),
|
||||||
|
|
||||||
-- If record is true, the stream should start recording when moonfire
|
-- A serialized json.StreamConfig
|
||||||
-- starts. If false, no new recordings will be made, but old recordings
|
config text not null,
|
||||||
-- will not be deleted.
|
|
||||||
record integer not null check (record in (1, 0)),
|
|
||||||
|
|
||||||
-- 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.
|
|
||||||
retain_bytes integer not null check (retain_bytes >= 0),
|
|
||||||
|
|
||||||
-- Flush the database when the first instant of completed recording is this
|
|
||||||
-- many seconds old. A value of 0 means that every completed recording will
|
|
||||||
-- cause an immediate flush. Higher values may allow flushes to be combined,
|
|
||||||
-- reducing SSD write cycles. For example, if all streams have a flush_if_sec
|
|
||||||
-- >= x sec, there will be:
|
|
||||||
--
|
|
||||||
-- * at most one flush per x sec in total
|
|
||||||
-- * at most x sec of completed but unflushed recordings per stream.
|
|
||||||
-- * at most x completed but unflushed recordings per stream, in the worst
|
|
||||||
-- case where a recording instantly fails, waits the 1-second retry delay,
|
|
||||||
-- then fails again, forever.
|
|
||||||
flush_if_sec integer not null,
|
|
||||||
|
|
||||||
-- The total number of recordings ever created on this stream, including
|
-- The total number of recordings ever created on this stream, including
|
||||||
-- deleted ones. This is used for assigning the next recording id.
|
-- deleted ones. This is used for assigning the next recording id.
|
||||||
|
@ -63,7 +63,7 @@ impl<C: Clocks + Clone> TestDb<C> {
|
|||||||
Self::new_with_flush_if_sec(clocks, 0)
|
Self::new_with_flush_if_sec(clocks, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_with_flush_if_sec(clocks: C, flush_if_sec: i64) -> Self {
|
pub(crate) fn new_with_flush_if_sec(clocks: C, flush_if_sec: u32) -> Self {
|
||||||
let tmpdir = tempfile::Builder::new()
|
let tmpdir = tempfile::Builder::new()
|
||||||
.prefix("moonfire-nvr-test")
|
.prefix("moonfire-nvr-test")
|
||||||
.tempdir()
|
.tempdir()
|
||||||
@ -82,17 +82,22 @@ impl<C: Clocks + Clone> TestDb<C> {
|
|||||||
TEST_CAMERA_ID,
|
TEST_CAMERA_ID,
|
||||||
l.add_camera(db::CameraChange {
|
l.add_camera(db::CameraChange {
|
||||||
short_name: "test camera".to_owned(),
|
short_name: "test camera".to_owned(),
|
||||||
description: "".to_owned(),
|
config: crate::json::CameraConfig::default(),
|
||||||
onvif_host: "test-camera".to_owned(),
|
//description: "".to_owned(),
|
||||||
username: Some("foo".to_owned()),
|
//onvif_host: "test-camera".to_owned(),
|
||||||
password: Some("bar".to_owned()),
|
//username: Some("foo".to_owned()),
|
||||||
|
//password: Some("bar".to_owned()),
|
||||||
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: Some(url::Url::parse("rtsp://test-camera/main").unwrap()),
|
config: crate::json::StreamConfig {
|
||||||
record: true,
|
url: Some(url::Url::parse("rtsp://test-camera/main").unwrap()),
|
||||||
|
mode: crate::json::STREAM_MODE_RECORD.to_owned(),
|
||||||
flush_if_sec,
|
flush_if_sec,
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
Default::default(),
|
||||||
Default::default(),
|
Default::default(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
@ -4,8 +4,226 @@
|
|||||||
|
|
||||||
/// Upgrades a version 6 schema to a version 7 schema.
|
/// Upgrades a version 6 schema to a version 7 schema.
|
||||||
use failure::Error;
|
use failure::Error;
|
||||||
|
use rusqlite::{named_params, params};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
|
use crate::{json::CameraConfig, FromSqlUuid};
|
||||||
tx.execute_batch("alter table user add preferences text")?;
|
|
||||||
|
fn copy_cameras(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||||
|
let mut insert = tx.prepare(
|
||||||
|
r#"
|
||||||
|
insert into camera (id, short_name, uuid, config)
|
||||||
|
values (:id, :short_name, :uuid, :config)
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
uuid,
|
||||||
|
short_name,
|
||||||
|
description,
|
||||||
|
onvif_host,
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
from
|
||||||
|
old_camera
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
let mut rows = stmt.query(params![])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let id: i32 = row.get(0)?;
|
||||||
|
let uuid: FromSqlUuid = row.get(1)?;
|
||||||
|
let uuid_bytes = &uuid.0.as_bytes()[..];
|
||||||
|
let short_name: String = row.get(2)?;
|
||||||
|
let mut description: Option<String> = row.get(3)?;
|
||||||
|
let onvif_host: Option<String> = row.get(4)?;
|
||||||
|
let mut username: Option<String> = row.get(5)?;
|
||||||
|
let mut password: Option<String> = row.get(6)?;
|
||||||
|
let config = CameraConfig {
|
||||||
|
description: description.take().unwrap_or_default(),
|
||||||
|
onvif_base_url: onvif_host
|
||||||
|
.map(|h| Url::parse(&format!("rtsp://{}/", h)))
|
||||||
|
.transpose()?,
|
||||||
|
username: username.take().unwrap_or_default(),
|
||||||
|
password: password.take().unwrap_or_default(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
insert.execute(named_params! {
|
||||||
|
":id": id,
|
||||||
|
":uuid": uuid_bytes,
|
||||||
|
":short_name": short_name,
|
||||||
|
":config": config,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_streams(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||||
|
let mut insert = tx.prepare(
|
||||||
|
r#"
|
||||||
|
insert into stream (id, camera_id, sample_file_dir_id, type, config, cum_recordings,
|
||||||
|
cum_media_duration_90k, cum_runs)
|
||||||
|
values (:id, :camera_id, :sample_file_dir_id, :type, :config, :cum_recordings,
|
||||||
|
:cum_media_duration_90k, :cum_runs)
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
camera_id,
|
||||||
|
sample_file_dir_id,
|
||||||
|
type,
|
||||||
|
record,
|
||||||
|
rtsp_url,
|
||||||
|
retain_bytes,
|
||||||
|
flush_if_sec,
|
||||||
|
cum_recordings,
|
||||||
|
cum_media_duration_90k,
|
||||||
|
cum_runs
|
||||||
|
from
|
||||||
|
old_stream
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
let mut rows = stmt.query(params![])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let id: i32 = row.get(0)?;
|
||||||
|
let camera_id: i32 = row.get(1)?;
|
||||||
|
let sample_file_dir_id: i32 = row.get(2)?;
|
||||||
|
let type_: String = row.get(3)?;
|
||||||
|
let record: bool = row.get(4)?;
|
||||||
|
let rtsp_url: String = row.get(5)?;
|
||||||
|
let retain_bytes: i64 = row.get(6)?;
|
||||||
|
let flush_if_sec: u32 = row.get(7)?;
|
||||||
|
let cum_recordings: i64 = row.get(8)?;
|
||||||
|
let cum_media_duration_90k: i64 = row.get(9)?;
|
||||||
|
let cum_runs: i64 = row.get(10)?;
|
||||||
|
let config = crate::json::StreamConfig {
|
||||||
|
mode: (if record {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
crate::json::STREAM_MODE_RECORD
|
||||||
|
})
|
||||||
|
.to_owned(),
|
||||||
|
url: Some(Url::parse(&rtsp_url)?),
|
||||||
|
retain_bytes,
|
||||||
|
flush_if_sec,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
insert.execute(named_params! {
|
||||||
|
":id": id,
|
||||||
|
":camera_id": camera_id,
|
||||||
|
":sample_file_dir_id": sample_file_dir_id,
|
||||||
|
":type": type_,
|
||||||
|
":config": config,
|
||||||
|
":cum_recordings": cum_recordings,
|
||||||
|
":cum_media_duration_90k": cum_media_duration_90k,
|
||||||
|
":cum_runs": cum_runs,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||||
|
tx.execute_batch(
|
||||||
|
r#"
|
||||||
|
|
||||||
|
alter table user add preferences text;
|
||||||
|
alter table camera rename to old_camera;
|
||||||
|
alter table stream rename to old_stream;
|
||||||
|
|
||||||
|
create table camera (
|
||||||
|
id integer primary key,
|
||||||
|
uuid blob unique not null check (length(uuid) = 16),
|
||||||
|
short_name text not null,
|
||||||
|
config text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
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', 'ext')),
|
||||||
|
config text not null,
|
||||||
|
cum_recordings integer not null check (cum_recordings >= 0),
|
||||||
|
cum_media_duration_90k integer not null check (cum_media_duration_90k >= 0),
|
||||||
|
cum_runs integer not null check (cum_runs >= 0),
|
||||||
|
unique (camera_id, type)
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
copy_cameras(tx)?;
|
||||||
|
copy_streams(tx)?;
|
||||||
|
tx.execute_batch(
|
||||||
|
r#"
|
||||||
|
drop index recording_cover;
|
||||||
|
|
||||||
|
alter table recording rename to old_recording;
|
||||||
|
create table recording (
|
||||||
|
composite_id integer primary key,
|
||||||
|
open_id integer not null,
|
||||||
|
stream_id integer not null references stream (id),
|
||||||
|
run_offset integer not null,
|
||||||
|
flags integer not null,
|
||||||
|
sample_file_bytes integer not null check (sample_file_bytes > 0),
|
||||||
|
start_time_90k integer not null check (start_time_90k > 0),
|
||||||
|
prev_media_duration_90k integer not null check (prev_media_duration_90k >= 0),
|
||||||
|
prev_runs integer not null check (prev_runs >= 0),
|
||||||
|
wall_duration_90k integer not null
|
||||||
|
check (wall_duration_90k >= 0 and wall_duration_90k < 5*60*90000),
|
||||||
|
media_duration_delta_90k integer not null,
|
||||||
|
video_samples integer not null check (video_samples > 0),
|
||||||
|
video_sync_samples integer not null check (video_sync_samples > 0),
|
||||||
|
video_sample_entry_id integer references video_sample_entry (id),
|
||||||
|
check (composite_id >> 32 = stream_id)
|
||||||
|
);
|
||||||
|
create index recording_cover on recording (
|
||||||
|
stream_id,
|
||||||
|
start_time_90k,
|
||||||
|
open_id,
|
||||||
|
wall_duration_90k,
|
||||||
|
media_duration_delta_90k,
|
||||||
|
video_samples,
|
||||||
|
video_sync_samples,
|
||||||
|
video_sample_entry_id,
|
||||||
|
sample_file_bytes,
|
||||||
|
run_offset,
|
||||||
|
flags
|
||||||
|
);
|
||||||
|
insert into recording select * from old_recording;
|
||||||
|
alter table recording_integrity rename to old_recording_integrity;
|
||||||
|
create table recording_integrity (
|
||||||
|
composite_id integer primary key references recording (composite_id),
|
||||||
|
local_time_delta_90k integer,
|
||||||
|
local_time_since_open_90k integer,
|
||||||
|
wall_time_delta_90k integer,
|
||||||
|
sample_file_blake3 blob check (length(sample_file_blake3) <= 32)
|
||||||
|
);
|
||||||
|
insert into recording_integrity select * from old_recording_integrity;
|
||||||
|
alter table recording_playback rename to old_recording_playback;
|
||||||
|
create table recording_playback (
|
||||||
|
composite_id integer primary key references recording (composite_id),
|
||||||
|
video_index blob not null check (length(video_index) > 0)
|
||||||
|
);
|
||||||
|
insert into recording_playback select * from old_recording_playback;
|
||||||
|
|
||||||
|
alter table signal_camera rename to old_signal_camera;
|
||||||
|
create table signal_camera (
|
||||||
|
signal_id integer references signal (id),
|
||||||
|
camera_id integer references camera (id),
|
||||||
|
type integer not null,
|
||||||
|
primary key (signal_id, camera_id)
|
||||||
|
) without rowid;
|
||||||
|
drop table old_signal_camera;
|
||||||
|
drop table old_recording_playback;
|
||||||
|
drop table old_recording_integrity;
|
||||||
|
drop table old_recording;
|
||||||
|
drop table old_stream;
|
||||||
|
drop table old_camera;
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -210,7 +210,7 @@ pub fn lower_retention(
|
|||||||
.ok_or_else(|| format_err!("no such stream {}", l.stream_id))?;
|
.ok_or_else(|| format_err!("no such stream {}", l.stream_id))?;
|
||||||
fs_bytes_before =
|
fs_bytes_before =
|
||||||
stream.fs_bytes + stream.fs_bytes_to_add - stream.fs_bytes_to_delete;
|
stream.fs_bytes + stream.fs_bytes_to_add - stream.fs_bytes_to_delete;
|
||||||
extra = stream.retain_bytes - l.limit;
|
extra = stream.config.retain_bytes - l.limit;
|
||||||
}
|
}
|
||||||
if l.limit >= fs_bytes_before {
|
if l.limit >= fs_bytes_before {
|
||||||
continue;
|
continue;
|
||||||
@ -235,14 +235,14 @@ fn delete_recordings(
|
|||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
};
|
};
|
||||||
stream.fs_bytes + stream.fs_bytes_to_add - stream.fs_bytes_to_delete + extra_bytes_needed
|
stream.fs_bytes + stream.fs_bytes_to_add - stream.fs_bytes_to_delete + extra_bytes_needed
|
||||||
- stream.retain_bytes
|
- stream.config.retain_bytes
|
||||||
};
|
};
|
||||||
let mut fs_bytes_to_delete = 0;
|
let mut fs_bytes_to_delete = 0;
|
||||||
if fs_bytes_needed <= 0 {
|
if fs_bytes_needed <= 0 {
|
||||||
debug!(
|
debug!(
|
||||||
"{}: have remaining quota of {}",
|
"{}: have remaining quota of {}",
|
||||||
stream_id,
|
stream_id,
|
||||||
base::strutil::encode_size(-fs_bytes_needed)
|
base::strutil::encode_size(fs_bytes_needed)
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@ -500,12 +500,13 @@ impl<C: Clocks + Clone, D: DirWriter> Syncer<C, D> {
|
|||||||
let c = db.cameras_by_id().get(&s.camera_id).unwrap();
|
let c = db.cameras_by_id().get(&s.camera_id).unwrap();
|
||||||
|
|
||||||
// Schedule a flush.
|
// Schedule a flush.
|
||||||
let how_soon = Duration::seconds(s.flush_if_sec) - wall_duration.to_tm_duration();
|
let how_soon =
|
||||||
|
Duration::seconds(i64::from(s.config.flush_if_sec)) - wall_duration.to_tm_duration();
|
||||||
let now = self.db.clocks().monotonic();
|
let now = self.db.clocks().monotonic();
|
||||||
let when = now + how_soon;
|
let when = now + how_soon;
|
||||||
let reason = format!(
|
let reason = format!(
|
||||||
"{} sec after start of {} {}-{} recording {}",
|
"{} sec after start of {} {}-{} recording {}",
|
||||||
s.flush_if_sec,
|
s.config.flush_if_sec,
|
||||||
wall_duration,
|
wall_duration,
|
||||||
c.short_name,
|
c.short_name,
|
||||||
s.type_.as_str(),
|
s.type_.as_str(),
|
||||||
@ -1054,7 +1055,7 @@ mod tests {
|
|||||||
syncer_rcv: mpsc::Receiver<super::SyncerCommand<MockFile>>,
|
syncer_rcv: mpsc::Receiver<super::SyncerCommand<MockFile>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_harness(flush_if_sec: i64) -> Harness {
|
fn new_harness(flush_if_sec: u32) -> Harness {
|
||||||
let clocks = SimulatedClocks::new(::time::Timespec::new(0, 0));
|
let clocks = SimulatedClocks::new(::time::Timespec::new(0, 0));
|
||||||
let tdb = testutil::TestDb::new_with_flush_if_sec(clocks, flush_if_sec);
|
let tdb = testutil::TestDb::new_with_flush_if_sec(clocks, flush_if_sec);
|
||||||
let dir_id = *tdb
|
let dir_id = *tdb
|
||||||
|
@ -4,104 +4,117 @@
|
|||||||
|
|
||||||
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, Scrollable};
|
||||||
use cursive::views::{self, ViewRef};
|
use cursive::views::{self, ViewRef};
|
||||||
use cursive::Cursive;
|
use cursive::Cursive;
|
||||||
use db::writer;
|
use db::writer;
|
||||||
use failure::{bail, Error, ResultExt};
|
use failure::{bail, format_err, Error, ResultExt};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
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`.
|
struct Camera {
|
||||||
fn get_change(siv: &mut Cursive) -> Result<db::CameraChange, Error> {
|
short_name: String,
|
||||||
|
description: String,
|
||||||
|
onvif_base_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
streams: [Stream; db::NUM_STREAM_TYPES],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Stream {
|
||||||
|
url: String,
|
||||||
|
record: bool,
|
||||||
|
flush_if_sec: String,
|
||||||
|
sample_file_dir_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `Camera` from an active `edit_camera_dialog`. No validation.
|
||||||
|
fn get_camera(siv: &mut Cursive) -> Camera {
|
||||||
// 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 short_name = siv
|
||||||
.find_name::<views::EditView>("short_name")
|
.find_name::<views::EditView>("short_name")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_content()
|
.get_content()
|
||||||
.as_str()
|
.as_str()
|
||||||
.into();
|
.into();
|
||||||
let d = siv
|
let description = siv
|
||||||
.find_name::<views::TextArea>("description")
|
.find_name::<views::TextArea>("description")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_content()
|
.get_content()
|
||||||
.into();
|
.into();
|
||||||
let h = siv
|
let onvif_base_url: String = siv
|
||||||
.find_name::<views::EditView>("onvif_host")
|
.find_name::<views::EditView>("onvif_base_url")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_content()
|
.get_content()
|
||||||
.as_str()
|
.as_str()
|
||||||
.into();
|
.into();
|
||||||
let username = match siv
|
let username = siv
|
||||||
.find_name::<views::EditView>("username")
|
.find_name::<views::EditView>("username")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_content()
|
.get_content()
|
||||||
.as_str()
|
.as_str()
|
||||||
{
|
.to_owned();
|
||||||
"" => None,
|
let password = siv
|
||||||
u => Some(u.to_owned()),
|
|
||||||
};
|
|
||||||
let password = match siv
|
|
||||||
.find_name::<views::EditView>("password")
|
.find_name::<views::EditView>("password")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_content()
|
.get_content()
|
||||||
.as_str()
|
.as_str()
|
||||||
{
|
.to_owned();
|
||||||
"" => None,
|
let mut camera = Camera {
|
||||||
p => Some(p.to_owned()),
|
short_name,
|
||||||
};
|
description,
|
||||||
let mut c = db::CameraChange {
|
onvif_base_url,
|
||||||
short_name: sn,
|
|
||||||
description: d,
|
|
||||||
onvif_host: h,
|
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
streams: Default::default(),
|
streams: Default::default(),
|
||||||
};
|
};
|
||||||
for &t in &db::ALL_STREAM_TYPES {
|
for &t in &db::ALL_STREAM_TYPES {
|
||||||
let rtsp_url = parse_url(
|
let url = siv
|
||||||
siv.find_name::<views::EditView>(&format!("{}_rtsp_url", t.as_str()))
|
.find_name::<views::EditView>(&format!("{}_url", t.as_str()))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_content()
|
.get_content()
|
||||||
.as_str(),
|
.as_str()
|
||||||
)?;
|
.to_owned();
|
||||||
let record = 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 flush_if_sec = i64::from_str(
|
let flush_if_sec = siv
|
||||||
siv.find_name::<views::EditView>(&format!("{}_flush_if_sec", t.as_str()))
|
.find_name::<views::EditView>(&format!("{}_flush_if_sec", t.as_str()))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_content()
|
.get_content()
|
||||||
.as_str(),
|
.as_str()
|
||||||
)
|
.to_owned();
|
||||||
.unwrap_or(0);
|
|
||||||
let sample_file_dir_id = *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 {
|
camera.streams[t.index()] = Stream {
|
||||||
rtsp_url,
|
url,
|
||||||
sample_file_dir_id,
|
|
||||||
record,
|
record,
|
||||||
flush_if_sec,
|
flush_if_sec,
|
||||||
|
sample_file_dir_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Ok(c)
|
camera
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to parse a URL field into a sort-of-validated URL.
|
/// Attempts to parse a URL field into a sort-of-validated URL.
|
||||||
fn parse_url(raw: &str) -> Result<Option<Url>, Error> {
|
fn parse_url(raw: &str, allowed_schemes: &'static [&'static str]) -> Result<Option<Url>, Error> {
|
||||||
if raw.is_empty() {
|
if raw.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let url = url::Url::parse(&raw).with_context(|_| format!("can't parse {:?} as URL", &raw))?;
|
let url = url::Url::parse(&raw).with_context(|_| format!("can't parse {:?} as URL", &raw))?;
|
||||||
if url.scheme() != "rtsp" {
|
if allowed_schemes
|
||||||
bail!("Expected URL scheme rtsp:// in URL {}", &url);
|
.iter()
|
||||||
|
.find(|scheme| **scheme == url.scheme())
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
bail!("Unexpected scheme in URL {}", &url);
|
||||||
}
|
}
|
||||||
if !url.username().is_empty() || url.password().is_some() {
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
bail!(
|
bail!(
|
||||||
@ -114,17 +127,46 @@ fn parse_url(raw: &str) -> Result<Option<Url>, Error> {
|
|||||||
|
|
||||||
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 result = (|| {
|
let result = (|| {
|
||||||
let change = get_change(siv)?;
|
let mut l = db.lock();
|
||||||
for (i, stream) in change.streams.iter().enumerate() {
|
let mut change = if let Some(id) = id {
|
||||||
if stream.record && (stream.rtsp_url.is_none() || stream.sample_file_dir_id.is_none()) {
|
l.null_camera_change(id)?
|
||||||
|
} else {
|
||||||
|
db::CameraChange::default()
|
||||||
|
};
|
||||||
|
let camera = get_camera(siv);
|
||||||
|
change.short_name = camera.short_name;
|
||||||
|
change.config.description = camera.description;
|
||||||
|
change.config.onvif_base_url = parse_url(&camera.onvif_base_url, &["http", "https"])?;
|
||||||
|
change.config.username = camera.username;
|
||||||
|
change.config.password = camera.password;
|
||||||
|
for (i, stream) in camera.streams.iter().enumerate() {
|
||||||
let type_ = db::StreamType::from_index(i).unwrap();
|
let type_ = db::StreamType::from_index(i).unwrap();
|
||||||
|
if stream.record && (stream.url.is_empty() || stream.sample_file_dir_id.is_none()) {
|
||||||
bail!(
|
bail!(
|
||||||
"Can't record {} stream without RTSP URL and sample file directory",
|
"Can't record {} stream without RTSP URL and sample file directory",
|
||||||
type_.as_str()
|
type_.as_str()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let stream_change = &mut change.streams[i];
|
||||||
|
stream_change.config.mode = (if stream.record {
|
||||||
|
db::json::STREAM_MODE_RECORD
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
})
|
||||||
|
.to_owned();
|
||||||
|
stream_change.config.url = parse_url(&stream.url, &["rtsp"])?;
|
||||||
|
stream_change.sample_file_dir_id = stream.sample_file_dir_id;
|
||||||
|
stream_change.config.flush_if_sec = if stream.flush_if_sec.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
stream.flush_if_sec.parse().map_err(|_| {
|
||||||
|
format_err!(
|
||||||
|
"flush_if_sec for {} must be a non-negative integer",
|
||||||
|
type_.as_str()
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
}
|
}
|
||||||
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 {
|
||||||
@ -146,17 +188,14 @@ fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn press_test_inner(
|
fn press_test_inner(url: Url, username: String, password: String) -> Result<String, Error> {
|
||||||
url: Url,
|
let pass_creds = !username.is_empty();
|
||||||
username: Option<String>,
|
|
||||||
password: Option<String>,
|
|
||||||
) -> Result<String, Error> {
|
|
||||||
let (extra_data, _stream) = stream::FFMPEG.open(
|
let (extra_data, _stream) = stream::FFMPEG.open(
|
||||||
"test stream".to_owned(),
|
"test stream".to_owned(),
|
||||||
stream::Source::Rtsp {
|
stream::Source::Rtsp {
|
||||||
url,
|
url,
|
||||||
username,
|
username: if pass_creds { Some(username) } else { None },
|
||||||
password,
|
password: if pass_creds { Some(password) } else { None },
|
||||||
transport: retina::client::Transport::Tcp,
|
transport: retina::client::Transport::Tcp,
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@ -167,21 +206,15 @@ fn press_test_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn press_test(siv: &mut Cursive, t: db::StreamType) {
|
fn press_test(siv: &mut Cursive, t: db::StreamType) {
|
||||||
let mut c = match get_change(siv) {
|
let c = get_camera(siv);
|
||||||
Ok(u) => u,
|
let url = &c.streams[t.index()].url;
|
||||||
Err(e) => {
|
let url = match parse_url(url, &["rtsp"]) {
|
||||||
siv.add_layer(
|
Ok(Some(u)) => u,
|
||||||
views::Dialog::text(format!("{}", e))
|
_ => panic!(
|
||||||
.title("Stream test failed")
|
"test button should only be enabled with valid URL, not {:?}",
|
||||||
.dismiss_button("Back"),
|
url
|
||||||
);
|
),
|
||||||
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;
|
||||||
|
|
||||||
@ -190,7 +223,7 @@ fn press_test(siv: &mut Cursive, t: db::StreamType) {
|
|||||||
"Testing {} stream at {}. This may take a while \
|
"Testing {} stream at {}. This may take a while \
|
||||||
on timeout or if you have a long key frame interval",
|
on timeout or if you have a long key frame interval",
|
||||||
t.as_str(),
|
t.as_str(),
|
||||||
&url
|
url.as_str()
|
||||||
))
|
))
|
||||||
.title("Testing"),
|
.title("Testing"),
|
||||||
);
|
);
|
||||||
@ -348,7 +381,7 @@ fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn edit_url(content: &str, mut test_button: ViewRef<views::Button>) {
|
fn edit_url(content: &str, mut test_button: ViewRef<views::Button>) {
|
||||||
let enable_test = matches!(parse_url(content), Ok(Some(_)));
|
let enable_test = matches!(parse_url(content, &["rtsp"]), Ok(Some(_)));
|
||||||
test_button.set_enabled(enable_test);
|
test_button.set_enabled(enable_test);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,7 +398,10 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||||||
)
|
)
|
||||||
.child("uuid", views::TextView::new("<new>").with_name("uuid"))
|
.child("uuid", views::TextView::new("<new>").with_name("uuid"))
|
||||||
.child("short name", views::EditView::new().with_name("short_name"))
|
.child("short name", views::EditView::new().with_name("short_name"))
|
||||||
.child("onvif_host", views::EditView::new().with_name("onvif_host"))
|
.child(
|
||||||
|
"onvif_base_url",
|
||||||
|
views::EditView::new().with_name("onvif_base_url"),
|
||||||
|
)
|
||||||
.child("username", views::EditView::new().with_name("username"))
|
.child("username", views::EditView::new().with_name("username"))
|
||||||
.child("password", views::EditView::new().with_name("password"))
|
.child("password", views::EditView::new().with_name("password"))
|
||||||
.min_height(6);
|
.min_height(6);
|
||||||
@ -399,7 +435,7 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
edit_url(content, test_button)
|
edit_url(content, test_button)
|
||||||
})
|
})
|
||||||
.with_name(format!("{}_rtsp_url", type_.as_str()))
|
.with_name(format!("{}_url", type_.as_str()))
|
||||||
.full_width(),
|
.full_width(),
|
||||||
)
|
)
|
||||||
.child(views::DummyView)
|
.child(views::DummyView)
|
||||||
@ -434,7 +470,7 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||||||
layout.add_child(list);
|
layout.add_child(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dialog = views::Dialog::around(layout);
|
let mut dialog = views::Dialog::around(layout.scrollable());
|
||||||
let dialog = if let Some(camera_id) = *item {
|
let dialog = if let Some(camera_id) = *item {
|
||||||
let l = db.lock();
|
let l = db.lock();
|
||||||
let camera = l.cameras_by_id().get(&camera_id).expect("missing camera");
|
let camera = l.cameras_by_id().get(&camera_id).expect("missing camera");
|
||||||
@ -460,35 +496,41 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bytes += s.sample_file_bytes;
|
bytes += s.sample_file_bytes;
|
||||||
let u = if s.retain_bytes == 0 {
|
let u = if s.config.retain_bytes == 0 {
|
||||||
"0 / 0 (0.0%)".to_owned()
|
"0 / 0 (0.0%)".to_owned()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"{} / {} ({:.1}%)",
|
"{} / {} ({:.1}%)",
|
||||||
s.fs_bytes,
|
s.fs_bytes,
|
||||||
s.retain_bytes,
|
s.config.retain_bytes,
|
||||||
100. * s.fs_bytes as f32 / s.retain_bytes as f32
|
100. * s.fs_bytes as f32 / s.config.retain_bytes as f32
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
dialog.call_on_name(
|
dialog.call_on_name(&format!("{}_url", t.as_str()), |v: &mut views::EditView| {
|
||||||
&format!("{}_rtsp_url", t.as_str()),
|
if let Some(url) = s.config.url.as_ref() {
|
||||||
|v: &mut views::EditView| v.set_content(s.rtsp_url.to_owned()),
|
v.set_content(url.as_str().to_owned());
|
||||||
);
|
}
|
||||||
|
});
|
||||||
let test_button = dialog
|
let test_button = dialog
|
||||||
.find_name::<views::Button>(&format!("{}_test", t.as_str()))
|
.find_name::<views::Button>(&format!("{}_test", t.as_str()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
edit_url(&s.rtsp_url, test_button);
|
edit_url(
|
||||||
|
&s.config.url.as_ref().map(Url::as_str).unwrap_or(""),
|
||||||
|
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),
|
||||||
);
|
);
|
||||||
dialog.call_on_name(
|
dialog.call_on_name(
|
||||||
&format!("{}_record", t.as_str()),
|
&format!("{}_record", t.as_str()),
|
||||||
|v: &mut views::Checkbox| v.set_checked(s.record),
|
|v: &mut views::Checkbox| {
|
||||||
|
v.set_checked(s.config.mode == db::json::STREAM_MODE_RECORD)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
dialog.call_on_name(
|
dialog.call_on_name(
|
||||||
&format!("{}_flush_if_sec", t.as_str()),
|
&format!("{}_flush_if_sec", t.as_str()),
|
||||||
|v: &mut views::EditView| v.set_content(s.flush_if_sec.to_string()),
|
|v: &mut views::EditView| v.set_content(s.config.flush_if_sec.to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
dialog.call_on_name(
|
dialog.call_on_name(
|
||||||
@ -499,9 +541,17 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||||||
let name = camera.short_name.clone();
|
let name = camera.short_name.clone();
|
||||||
for &(view_id, content) in &[
|
for &(view_id, content) in &[
|
||||||
("short_name", &*camera.short_name),
|
("short_name", &*camera.short_name),
|
||||||
("onvif_host", &*camera.onvif_host),
|
(
|
||||||
("username", camera.username.as_deref().unwrap_or("")),
|
"onvif_base_url",
|
||||||
("password", camera.password.as_deref().unwrap_or("")),
|
&camera
|
||||||
|
.config
|
||||||
|
.onvif_base_url
|
||||||
|
.as_ref()
|
||||||
|
.map(Url::as_str)
|
||||||
|
.unwrap_or(""),
|
||||||
|
),
|
||||||
|
("username", &camera.config.username),
|
||||||
|
("password", &camera.config.password),
|
||||||
] {
|
] {
|
||||||
dialog
|
dialog
|
||||||
.call_on_name(view_id, |v: &mut views::EditView| {
|
.call_on_name(view_id, |v: &mut views::EditView| {
|
||||||
@ -511,7 +561,7 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||||||
}
|
}
|
||||||
dialog
|
dialog
|
||||||
.call_on_name("description", |v: &mut views::TextArea| {
|
.call_on_name("description", |v: &mut views::TextArea| {
|
||||||
v.set_content(camera.description.to_string())
|
v.set_content(camera.config.description.clone())
|
||||||
})
|
})
|
||||||
.expect("missing TextArea");
|
.expect("missing TextArea");
|
||||||
dialog
|
dialog
|
||||||
|
@ -315,12 +315,12 @@ fn edit_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
|||||||
Stream {
|
Stream {
|
||||||
label: format!("{}: {}: {}", id, c.short_name, s.type_.as_str()),
|
label: format!("{}: {}: {}", id, c.short_name, s.type_.as_str()),
|
||||||
used: s.fs_bytes,
|
used: s.fs_bytes,
|
||||||
record: s.record,
|
record: s.config.mode == db::json::STREAM_MODE_RECORD,
|
||||||
retain: Some(s.retain_bytes),
|
retain: Some(s.config.retain_bytes),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
total_used += s.fs_bytes;
|
total_used += s.fs_bytes;
|
||||||
total_retain += s.retain_bytes;
|
total_retain += s.config.retain_bytes;
|
||||||
}
|
}
|
||||||
if streams.is_empty() {
|
if streams.is_empty() {
|
||||||
return delete_dir_dialog(db, siv, dir_id);
|
return delete_dir_dialog(db, siv, dir_id);
|
||||||
|
@ -232,12 +232,20 @@ async fn async_run(args: &Args) -> Result<i32, Error> {
|
|||||||
|
|
||||||
// Get the directories that need syncers.
|
// Get the directories that need syncers.
|
||||||
for stream in l.streams_by_id().values() {
|
for stream in l.streams_by_id().values() {
|
||||||
if let (Some(id), true) = (stream.sample_file_dir_id, stream.record) {
|
if stream.config.mode != db::json::STREAM_MODE_RECORD {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(id) = stream.sample_file_dir_id {
|
||||||
dirs.entry(id).or_insert_with(|| {
|
dirs.entry(id).or_insert_with(|| {
|
||||||
let d = l.sample_file_dirs_by_id().get(&id).unwrap();
|
let d = l.sample_file_dirs_by_id().get(&id).unwrap();
|
||||||
info!("Starting syncer for path {}", d.path);
|
info!("Starting syncer for path {}", d.path);
|
||||||
d.get().unwrap()
|
d.get().unwrap()
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"Stream {} set to record but has no sample file dir id",
|
||||||
|
stream.id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,7 +261,7 @@ async fn async_run(args: &Args) -> Result<i32, Error> {
|
|||||||
let handle = tokio::runtime::Handle::current();
|
let handle = tokio::runtime::Handle::current();
|
||||||
let l = db.lock();
|
let l = db.lock();
|
||||||
for (i, (id, stream)) in l.streams_by_id().iter().enumerate() {
|
for (i, (id, stream)) in l.streams_by_id().iter().enumerate() {
|
||||||
if !stream.record {
|
if stream.config.mode != db::json::STREAM_MODE_RECORD {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let camera = l.cameras_by_id().get(&stream.camera_id).unwrap();
|
let camera = l.cameras_by_id().get(&stream.camera_id).unwrap();
|
||||||
|
@ -56,10 +56,9 @@ pub struct Camera<'a> {
|
|||||||
pub uuid: Uuid,
|
pub uuid: Uuid,
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub short_name: &'a str,
|
pub short_name: &'a str,
|
||||||
pub description: &'a str,
|
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub config: Option<CameraConfig<'a>>,
|
pub config: Option<&'a db::json::CameraConfig>,
|
||||||
|
|
||||||
#[serde(serialize_with = "Camera::serialize_streams")]
|
#[serde(serialize_with = "Camera::serialize_streams")]
|
||||||
pub streams: [Option<Stream<'a>>; 2],
|
pub streams: [Option<Stream<'a>>; 2],
|
||||||
@ -90,13 +89,7 @@ pub struct Stream<'a> {
|
|||||||
pub days: Option<db::days::Map<db::days::StreamValue>>,
|
pub days: Option<db::days::Map<db::days::StreamValue>>,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub config: Option<StreamConfig<'a>>,
|
pub config: Option<&'a db::json::StreamConfig>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct StreamConfig<'a> {
|
|
||||||
pub rtsp_url: &'a str,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@ -188,14 +181,9 @@ impl<'a> Camera<'a> {
|
|||||||
uuid: c.uuid,
|
uuid: c.uuid,
|
||||||
id: c.id,
|
id: c.id,
|
||||||
short_name: &c.short_name,
|
short_name: &c.short_name,
|
||||||
description: &c.description,
|
|
||||||
config: match include_config {
|
config: match include_config {
|
||||||
false => None,
|
false => None,
|
||||||
true => Some(CameraConfig {
|
true => Some(&c.config),
|
||||||
onvif_host: &c.onvif_host,
|
|
||||||
username: c.username.as_deref(),
|
|
||||||
password: c.password.as_deref(),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
streams: [
|
streams: [
|
||||||
Stream::wrap(db, c.streams[0], include_days, include_config)?,
|
Stream::wrap(db, c.streams[0], include_days, include_config)?,
|
||||||
@ -240,19 +228,17 @@ impl<'a> Stream<'a> {
|
|||||||
.ok_or_else(|| format_err!("missing stream {}", id))?;
|
.ok_or_else(|| format_err!("missing stream {}", id))?;
|
||||||
Ok(Some(Stream {
|
Ok(Some(Stream {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
retain_bytes: s.retain_bytes,
|
retain_bytes: s.config.retain_bytes,
|
||||||
min_start_time_90k: s.range.as_ref().map(|r| r.start),
|
min_start_time_90k: s.range.as_ref().map(|r| r.start),
|
||||||
max_end_time_90k: s.range.as_ref().map(|r| r.end),
|
max_end_time_90k: s.range.as_ref().map(|r| r.end),
|
||||||
total_duration_90k: s.duration,
|
total_duration_90k: s.duration,
|
||||||
total_sample_file_bytes: s.sample_file_bytes,
|
total_sample_file_bytes: s.sample_file_bytes,
|
||||||
fs_bytes: s.fs_bytes,
|
fs_bytes: s.fs_bytes,
|
||||||
record: s.record,
|
record: s.config.mode == db::json::STREAM_MODE_RECORD,
|
||||||
days: if include_days { Some(s.days()) } else { None },
|
days: if include_days { Some(s.days()) } else { None },
|
||||||
config: match include_config {
|
config: match include_config {
|
||||||
false => None,
|
false => None,
|
||||||
true => Some(StreamConfig {
|
true => Some(&s.config),
|
||||||
rtsp_url: &s.rtsp_url,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
use crate::stream;
|
use crate::stream;
|
||||||
use base::clock::{Clocks, TimerGuard};
|
use base::clock::{Clocks, TimerGuard};
|
||||||
use db::{dir, recording, writer, Camera, Database, Stream};
|
use db::{dir, recording, writer, Camera, Database, Stream};
|
||||||
use failure::{bail, Error};
|
use failure::{bail, format_err, Error};
|
||||||
use log::{debug, info, trace, warn};
|
use log::{debug, info, trace, warn};
|
||||||
use std::result::Result;
|
use std::result::Result;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@ -44,8 +44,8 @@ where
|
|||||||
stream_id: i32,
|
stream_id: i32,
|
||||||
short_name: String,
|
short_name: String,
|
||||||
url: Url,
|
url: Url,
|
||||||
username: Option<String>,
|
username: String,
|
||||||
password: Option<String>,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, C> Streamer<'a, C>
|
impl<'a, C> Streamer<'a, C>
|
||||||
@ -62,7 +62,11 @@ where
|
|||||||
rotate_offset_sec: i64,
|
rotate_offset_sec: i64,
|
||||||
rotate_interval_sec: i64,
|
rotate_interval_sec: i64,
|
||||||
) -> Result<Self, Error> {
|
) -> Result<Self, Error> {
|
||||||
let url = Url::parse(&s.rtsp_url)?;
|
let url = s
|
||||||
|
.config
|
||||||
|
.url
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| format_err!("Stream has no RTSP URL"))?;
|
||||||
if !url.username().is_empty() || url.password().is_some() {
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
bail!("RTSP URL shouldn't include credentials");
|
bail!("RTSP URL shouldn't include credentials");
|
||||||
}
|
}
|
||||||
@ -77,9 +81,9 @@ where
|
|||||||
transport: env.transport,
|
transport: env.transport,
|
||||||
stream_id,
|
stream_id,
|
||||||
short_name: format!("{}-{}", c.short_name, s.type_.as_str()),
|
short_name: format!("{}-{}", c.short_name, s.type_.as_str()),
|
||||||
url,
|
url: url.clone(),
|
||||||
username: c.username.clone(),
|
username: c.config.username.clone(),
|
||||||
password: c.password.clone(),
|
password: c.config.password.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,8 +120,16 @@ where
|
|||||||
self.short_name.clone(),
|
self.short_name.clone(),
|
||||||
stream::Source::Rtsp {
|
stream::Source::Rtsp {
|
||||||
url: self.url.clone(),
|
url: self.url.clone(),
|
||||||
username: self.username.clone(),
|
username: if self.username.is_empty() {
|
||||||
password: self.password.clone(),
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.username.clone())
|
||||||
|
},
|
||||||
|
password: if self.password.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(self.password.clone())
|
||||||
|
},
|
||||||
transport: self.transport,
|
transport: self.transport,
|
||||||
},
|
},
|
||||||
)?
|
)?
|
||||||
|
Loading…
x
Reference in New Issue
Block a user