diff --git a/server/src/web/mod.rs b/server/src/web/mod.rs
index 996ca87..f6b9a0b 100644
--- a/server/src/web/mod.rs
+++ b/server/src/web/mod.rs
@@ -5,6 +5,7 @@
mod live;
mod path;
mod static_file;
+mod view;
use self::path::Path;
use crate::body::Body;
@@ -23,16 +24,10 @@ use http::method::Method;
use http::{status::StatusCode, Request, Response};
use http_serve::dir::FsDir;
use hyper::body::Bytes;
-use log::{debug, info, trace, warn};
+use log::{debug, info, warn};
use memchr::memchr;
-use nom::bytes::complete::{tag, take_while1};
-use nom::combinator::{all_consuming, map, map_res, opt};
-use nom::sequence::{preceded, tuple};
-use nom::IResult;
-use std::cmp;
use std::convert::TryFrom;
use std::net::IpAddr;
-use std::ops::Range;
use std::sync::Arc;
use url::form_urlencoded;
use uuid::Uuid;
@@ -92,70 +87,6 @@ fn from_base_error(err: base::Error) -> Response
{
plain_response(status_code, err.to_string())
}
-#[derive(Debug, Eq, PartialEq)]
-struct Segments {
- ids: Range,
- open_id: Option,
- start_time: i64,
- end_time: Option,
-}
-
-fn num<'a, T: FromStr>() -> impl FnMut(&'a str) -> IResult<&'a str, T> {
- map_res(take_while1(|c: char| c.is_ascii_digit()), FromStr::from_str)
-}
-
-impl Segments {
- /// Parses the `s` query parameter to `view.mp4` as described in `design/api.md`.
- /// Doesn't do any validation.
- fn parse(i: &str) -> IResult<&str, Segments> {
- // Parse START_ID[-END_ID] into Range.
- // Note that END_ID is inclusive, but Ranges are half-open.
- let (i, ids) = map(
- tuple((num::(), opt(preceded(tag("-"), num::())))),
- |(start, end)| start..end.unwrap_or(start) + 1,
- )(i)?;
-
- // Parse [@OPEN_ID] into Option.
- let (i, open_id) = opt(preceded(tag("@"), num::()))(i)?;
-
- // Parse [.[REL_START_TIME]-[REL_END_TIME]] into (i64, Option).
- let (i, (start_time, end_time)) = map(
- opt(preceded(
- tag("."),
- tuple((opt(num::()), tag("-"), opt(num::()))),
- )),
- |t| t.map(|(s, _, e)| (s.unwrap_or(0), e)).unwrap_or((0, None)),
- )(i)?;
-
- Ok((
- i,
- Segments {
- ids,
- open_id,
- start_time,
- end_time,
- },
- ))
- }
-}
-
-impl FromStr for Segments {
- type Err = ();
-
- fn from_str(s: &str) -> Result {
- let (_, s) = all_consuming(Segments::parse)(s).map_err(|_| ())?;
- if s.ids.end <= s.ids.start {
- return Err(());
- }
- if let Some(e) = s.end_time {
- if e < s.start_time {
- return Err(());
- }
- }
- Ok(s)
- }
-}
-
struct Caller {
permissions: db::Permissions,
user: Option,
@@ -537,197 +468,6 @@ impl Service {
}
}
- fn stream_view_mp4(
- &self,
- req: &Request<::hyper::Body>,
- caller: Caller,
- uuid: Uuid,
- stream_type: db::StreamType,
- mp4_type: mp4::Type,
- debug: bool,
- ) -> ResponseResult {
- if !caller.permissions.view_video {
- bail_t!(PermissionDenied, "view_video required");
- }
- let (stream_id, camera_name);
- {
- let db = self.db.lock();
- let camera = db.get_camera(uuid).ok_or_else(|| {
- plain_response(StatusCode::NOT_FOUND, format!("no such camera {}", uuid))
- })?;
- camera_name = camera.short_name.clone();
- stream_id = camera.streams[stream_type.index()].ok_or_else(|| {
- plain_response(
- StatusCode::NOT_FOUND,
- format!("no such stream {}/{}", uuid, stream_type),
- )
- })?;
- };
- let mut start_time_for_filename = None;
- let mut builder = mp4::FileBuilder::new(mp4_type);
- if let Some(q) = req.uri().query() {
- for (key, value) in form_urlencoded::parse(q.as_bytes()) {
- let (key, value) = (key.borrow(), value.borrow());
- match key {
- "s" => {
- let s = Segments::from_str(value).map_err(|()| {
- plain_response(
- StatusCode::BAD_REQUEST,
- format!("invalid s parameter: {}", value),
- )
- })?;
- trace!("stream_view_mp4: appending s={:?}", s);
- let mut est_segments = usize::try_from(s.ids.end - s.ids.start).unwrap();
- if let Some(end) = s.end_time {
- // There should be roughly ceil((end - start) /
- // desired_recording_duration) recordings in the desired timespan if
- // there are no gaps or overlap, possibly another for misalignment of
- // the requested timespan with the rotate offset and another because
- // rotation only happens at key frames.
- let ceil_durations = (end - s.start_time
- + recording::DESIRED_RECORDING_WALL_DURATION
- - 1)
- / recording::DESIRED_RECORDING_WALL_DURATION;
- est_segments = cmp::min(est_segments, (ceil_durations + 2) as usize);
- }
- builder.reserve(est_segments);
- let db = self.db.lock();
- let mut prev = None; // previous recording id
- let mut cur_off = 0;
- db.list_recordings_by_id(stream_id, s.ids.clone(), &mut |r| {
- let recording_id = r.id.recording();
-
- if let Some(o) = s.open_id {
- if r.open_id != o {
- bail_t!(
- NotFound,
- "recording {} has open id {}, requested {}",
- r.id,
- r.open_id,
- o
- );
- }
- }
-
- // Check for missing recordings.
- match prev {
- None if recording_id == s.ids.start => {}
- None => bail_t!(
- NotFound,
- "no such recording {}/{}",
- stream_id,
- s.ids.start
- ),
- Some(id) if r.id.recording() != id + 1 => {
- bail_t!(NotFound, "no such recording {}/{}", stream_id, id + 1);
- }
- _ => {}
- };
- prev = Some(recording_id);
-
- // Add a segment for the relevant part of the recording, if any.
- // Note all calculations here are in wall times / wall durations.
- let end_time = s.end_time.unwrap_or(i64::max_value());
- let wd = i64::from(r.wall_duration_90k);
- if s.start_time <= cur_off + wd && cur_off < end_time {
- let start = cmp::max(0, s.start_time - cur_off);
- let end = cmp::min(wd, end_time - cur_off);
- let wr = i32::try_from(start).unwrap()..i32::try_from(end).unwrap();
- trace!(
- "...appending recording {} with wall duration {:?} \
- (out of total {})",
- r.id,
- wr,
- wd
- );
- if start_time_for_filename.is_none() {
- start_time_for_filename =
- Some(r.start + recording::Duration(start));
- }
- use recording::rescale;
- let mr =
- rescale(wr.start, r.wall_duration_90k, r.media_duration_90k)
- ..rescale(
- wr.end,
- r.wall_duration_90k,
- r.media_duration_90k,
- );
- builder.append(&db, r, mr, true)?;
- } else {
- trace!("...skipping recording {} wall dur {}", r.id, wd);
- }
- cur_off += wd;
- Ok(())
- })?;
-
- // Check for missing recordings.
- match prev {
- Some(id) if s.ids.end != id + 1 => {
- return Err(not_found(format!(
- "no such recording {}/{}",
- stream_id,
- s.ids.end - 1
- )));
- }
- None => {
- return Err(not_found(format!(
- "no such recording {}/{}",
- stream_id, s.ids.start
- )));
- }
- _ => {}
- };
- if let Some(end) = s.end_time {
- if end > cur_off {
- bail_t!(
- InvalidArgument,
- "end time {} is beyond specified recordings",
- end
- );
- }
- }
- }
- "ts" => builder
- .include_timestamp_subtitle_track(value == "true")
- .map_err(from_base_error)?,
- _ => return Err(bad_req(format!("parameter {} not understood", key))),
- }
- }
- }
- if let Some(start) = start_time_for_filename {
- let tm = time::at(time::Timespec {
- sec: start.unix_seconds(),
- nsec: 0,
- });
- let stream_abbrev = if stream_type == db::StreamType::Main {
- "main"
- } else {
- "sub"
- };
- let suffix = if mp4_type == mp4::Type::Normal {
- "mp4"
- } else {
- "m4s"
- };
- builder
- .set_filename(&format!(
- "{}-{}-{}.{}",
- tm.strftime("%Y%m%d%H%M%S").unwrap(),
- camera_name,
- stream_abbrev,
- suffix
- ))
- .map_err(from_base_error)?;
- }
- let mp4 = builder
- .build(self.db.clone(), self.dirs_by_stream_id.clone())
- .map_err(from_base_error)?;
- if debug {
- return Ok(plain_response(StatusCode::OK, format!("{:#?}", mp4)));
- }
- Ok(http_serve::serve(mp4, req))
- }
-
async fn user(&self, req: Request, caller: Caller, id: i32) -> ResponseResult {
if caller.user.map(|u| u.id) != Some(id) {
bail_t!(Unauthenticated, "must be authenticated as supplied user");
@@ -1052,24 +792,22 @@ fn encode_sid(sid: db::RawSessionId, flags: i32) -> String {
#[cfg(test)]
mod tests {
- use super::Segments;
use db::testutil::{self, TestDb};
use futures::future::FutureExt;
use log::info;
use std::collections::HashMap;
- use std::str::FromStr;
use std::sync::Arc;
- struct Server {
- db: TestDb,
- base_url: String,
+ pub(super) struct Server {
+ pub(super) db: TestDb,
+ pub(super) base_url: String,
//test_camera_uuid: Uuid,
handle: Option<::std::thread::JoinHandle<()>>,
shutdown_tx: Option>,
}
impl Server {
- fn new(allow_unauthenticated_permissions: Option) -> Server {
+ pub(super) fn new(allow_unauthenticated_permissions: Option) -> Server {
let db = TestDb::new(base::clock::RealClocks {});
let (shutdown_tx, shutdown_rx) = futures::channel::oneshot::channel::<()>();
let service = Arc::new(
@@ -1159,52 +897,6 @@ mod tests {
}
}
- #[test]
- #[rustfmt::skip]
- fn test_segments() {
- testutil::init();
- assert_eq!(
- Segments { ids: 1..2, open_id: None, start_time: 0, end_time: None },
- Segments::from_str("1").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..2, open_id: Some(42), start_time: 0, end_time: None },
- Segments::from_str("1@42").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..2, open_id: None, start_time: 26, end_time: None },
- Segments::from_str("1.26-").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..2, open_id: Some(42), start_time: 26, end_time: None },
- Segments::from_str("1@42.26-").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..2, open_id: None, start_time: 0, end_time: Some(42) },
- Segments::from_str("1.-42").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..2, open_id: None, start_time: 26, end_time: Some(42) },
- Segments::from_str("1.26-42").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..6, open_id: None, start_time: 0, end_time: None },
- Segments::from_str("1-5").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..6, open_id: None, start_time: 26, end_time: None },
- Segments::from_str("1-5.26-").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..6, open_id: None, start_time: 0, end_time: Some(42) },
- Segments::from_str("1-5.-42").unwrap()
- );
- assert_eq!(
- Segments { ids: 1..6, open_id: None, start_time: 26, end_time: Some(42) },
- Segments::from_str("1-5.26-42").unwrap()
- );
- }
-
#[tokio::test]
async fn unauthorized_without_cookie() {
testutil::init();
@@ -1333,24 +1025,6 @@ mod tests {
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
}
- #[tokio::test]
- async fn view_without_segments() {
- testutil::init();
- let mut permissions = db::Permissions::new();
- permissions.view_video = true;
- let s = Server::new(Some(permissions));
- let cli = reqwest::Client::new();
- let resp = cli
- .get(&format!(
- "{}/api/cameras/{}/main/view.mp4",
- &s.base_url, s.db.test_camera_uuid
- ))
- .send()
- .await
- .unwrap();
- assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST);
- }
-
#[test]
fn encode_sid() {
use super::encode_sid;
diff --git a/server/src/web/view.rs b/server/src/web/view.rs
new file mode 100644
index 0000000..d4a7d67
--- /dev/null
+++ b/server/src/web/view.rs
@@ -0,0 +1,355 @@
+// 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.
+
+//! `/view.mp4` and `/view.m4s` handling.
+
+use base::bail_t;
+use db::recording::{self, rescale};
+use http::{Request, StatusCode};
+use log::trace;
+use nom::bytes::complete::{tag, take_while1};
+use nom::combinator::{all_consuming, map, map_res, opt};
+use nom::sequence::{preceded, tuple};
+use nom::IResult;
+use std::borrow::Borrow;
+use std::cmp;
+use std::convert::TryFrom;
+use std::ops::Range;
+use std::str::FromStr;
+use url::form_urlencoded;
+use uuid::Uuid;
+
+use crate::web::{bad_req, from_base_error};
+use crate::{
+ mp4,
+ web::{not_found, plain_response},
+};
+
+use super::{Caller, ResponseResult, Service};
+
+impl Service {
+ pub(super) fn stream_view_mp4(
+ &self,
+ req: &Request<::hyper::Body>,
+ caller: Caller,
+ uuid: Uuid,
+ stream_type: db::StreamType,
+ mp4_type: mp4::Type,
+ debug: bool,
+ ) -> ResponseResult {
+ if !caller.permissions.view_video {
+ bail_t!(PermissionDenied, "view_video required");
+ }
+ let (stream_id, camera_name);
+ {
+ let db = self.db.lock();
+ let camera = db
+ .get_camera(uuid)
+ .ok_or_else(|| not_found(format!("no such camera {}", uuid)))?;
+ camera_name = camera.short_name.clone();
+ stream_id = camera.streams[stream_type.index()]
+ .ok_or_else(|| not_found(format!("no such stream {}/{}", uuid, stream_type)))?;
+ };
+ let mut start_time_for_filename = None;
+ let mut builder = mp4::FileBuilder::new(mp4_type);
+ if let Some(q) = req.uri().query() {
+ for (key, value) in form_urlencoded::parse(q.as_bytes()) {
+ let (key, value) = (key.borrow(), value.borrow());
+ match key {
+ "s" => {
+ let s = Segments::from_str(value).map_err(|()| {
+ plain_response(
+ StatusCode::BAD_REQUEST,
+ format!("invalid s parameter: {}", value),
+ )
+ })?;
+ trace!("stream_view_mp4: appending s={:?}", s);
+ let mut est_segments = usize::try_from(s.ids.end - s.ids.start).unwrap();
+ if let Some(end) = s.end_time {
+ // There should be roughly ceil((end - start) /
+ // desired_recording_duration) recordings in the desired timespan if
+ // there are no gaps or overlap, possibly another for misalignment of
+ // the requested timespan with the rotate offset and another because
+ // rotation only happens at key frames.
+ let ceil_durations = (end - s.start_time
+ + recording::DESIRED_RECORDING_WALL_DURATION
+ - 1)
+ / recording::DESIRED_RECORDING_WALL_DURATION;
+ est_segments = cmp::min(est_segments, (ceil_durations + 2) as usize);
+ }
+ builder.reserve(est_segments);
+ let db = self.db.lock();
+ let mut prev = None; // previous recording id
+ let mut cur_off = 0;
+ db.list_recordings_by_id(stream_id, s.ids.clone(), &mut |r| {
+ let recording_id = r.id.recording();
+
+ if let Some(o) = s.open_id {
+ if r.open_id != o {
+ bail_t!(
+ NotFound,
+ "recording {} has open id {}, requested {}",
+ r.id,
+ r.open_id,
+ o
+ );
+ }
+ }
+
+ // Check for missing recordings.
+ match prev {
+ None if recording_id == s.ids.start => {}
+ None => bail_t!(
+ NotFound,
+ "no such recording {}/{}",
+ stream_id,
+ s.ids.start
+ ),
+ Some(id) if r.id.recording() != id + 1 => {
+ bail_t!(NotFound, "no such recording {}/{}", stream_id, id + 1);
+ }
+ _ => {}
+ };
+ prev = Some(recording_id);
+
+ // Add a segment for the relevant part of the recording, if any.
+ // Note all calculations here are in wall times / wall durations.
+ let end_time = s.end_time.unwrap_or(i64::max_value());
+ let wd = i64::from(r.wall_duration_90k);
+ if s.start_time <= cur_off + wd && cur_off < end_time {
+ let start = cmp::max(0, s.start_time - cur_off);
+ let end = cmp::min(wd, end_time - cur_off);
+ let wr = i32::try_from(start).unwrap()..i32::try_from(end).unwrap();
+ trace!(
+ "...appending recording {} with wall duration {:?} \
+ (out of total {})",
+ r.id,
+ wr,
+ wd
+ );
+ if start_time_for_filename.is_none() {
+ start_time_for_filename =
+ Some(r.start + recording::Duration(start));
+ }
+ let mr =
+ rescale(wr.start, r.wall_duration_90k, r.media_duration_90k)
+ ..rescale(
+ wr.end,
+ r.wall_duration_90k,
+ r.media_duration_90k,
+ );
+ builder.append(&db, r, mr, true)?;
+ } else {
+ trace!("...skipping recording {} wall dur {}", r.id, wd);
+ }
+ cur_off += wd;
+ Ok(())
+ })?;
+
+ // Check for missing recordings.
+ match prev {
+ Some(id) if s.ids.end != id + 1 => {
+ return Err(not_found(format!(
+ "no such recording {}/{}",
+ stream_id,
+ s.ids.end - 1
+ )));
+ }
+ None => {
+ return Err(not_found(format!(
+ "no such recording {}/{}",
+ stream_id, s.ids.start
+ )));
+ }
+ _ => {}
+ };
+ if let Some(end) = s.end_time {
+ if end > cur_off {
+ bail_t!(
+ InvalidArgument,
+ "end time {} is beyond specified recordings",
+ end
+ );
+ }
+ }
+ }
+ "ts" => builder
+ .include_timestamp_subtitle_track(value == "true")
+ .map_err(from_base_error)?,
+ _ => return Err(bad_req(format!("parameter {} not understood", key))),
+ }
+ }
+ }
+ if let Some(start) = start_time_for_filename {
+ let tm = time::at(time::Timespec {
+ sec: start.unix_seconds(),
+ nsec: 0,
+ });
+ let stream_abbrev = if stream_type == db::StreamType::Main {
+ "main"
+ } else {
+ "sub"
+ };
+ let suffix = if mp4_type == mp4::Type::Normal {
+ "mp4"
+ } else {
+ "m4s"
+ };
+ builder
+ .set_filename(&format!(
+ "{}-{}-{}.{}",
+ tm.strftime("%Y%m%d%H%M%S").unwrap(),
+ camera_name,
+ stream_abbrev,
+ suffix
+ ))
+ .map_err(from_base_error)?;
+ }
+ let mp4 = builder
+ .build(self.db.clone(), self.dirs_by_stream_id.clone())
+ .map_err(from_base_error)?;
+ if debug {
+ return Ok(plain_response(StatusCode::OK, format!("{:#?}", mp4)));
+ }
+ Ok(http_serve::serve(mp4, req))
+ }
+}
+
+/// Represents a single `s=` (segments) query parameter as supplied to `/view.mp4`.
+#[derive(Debug, Eq, PartialEq)]
+struct Segments {
+ ids: Range,
+ open_id: Option,
+ start_time: i64,
+ end_time: Option,
+}
+
+fn num<'a, T: FromStr>() -> impl FnMut(&'a str) -> IResult<&'a str, T> {
+ map_res(take_while1(|c: char| c.is_ascii_digit()), FromStr::from_str)
+}
+
+impl Segments {
+ /// Parses the `s` query parameter to `view.mp4` as described in `design/api.md`.
+ /// Doesn't do any validation.
+ fn parse(i: &str) -> IResult<&str, Segments> {
+ // Parse START_ID[-END_ID] into Range.
+ // Note that END_ID is inclusive, but Ranges are half-open.
+ let (i, ids) = map(
+ tuple((num::(), opt(preceded(tag("-"), num::())))),
+ |(start, end)| start..end.unwrap_or(start) + 1,
+ )(i)?;
+
+ // Parse [@OPEN_ID] into Option.
+ let (i, open_id) = opt(preceded(tag("@"), num::()))(i)?;
+
+ // Parse [.[REL_START_TIME]-[REL_END_TIME]] into (i64, Option).
+ let (i, (start_time, end_time)) = map(
+ opt(preceded(
+ tag("."),
+ tuple((opt(num::()), tag("-"), opt(num::()))),
+ )),
+ |t| t.map(|(s, _, e)| (s.unwrap_or(0), e)).unwrap_or((0, None)),
+ )(i)?;
+
+ Ok((
+ i,
+ Segments {
+ ids,
+ open_id,
+ start_time,
+ end_time,
+ },
+ ))
+ }
+}
+
+impl FromStr for Segments {
+ type Err = ();
+
+ fn from_str(s: &str) -> Result {
+ let (_, s) = all_consuming(Segments::parse)(s).map_err(|_| ())?;
+ if s.ids.end <= s.ids.start {
+ return Err(());
+ }
+ if let Some(e) = s.end_time {
+ if e < s.start_time {
+ return Err(());
+ }
+ }
+ Ok(s)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::web::tests::Server;
+ use db::testutil;
+ use std::str::FromStr;
+
+ use super::Segments;
+
+ #[tokio::test]
+ async fn view_without_segments() {
+ testutil::init();
+ let mut permissions = db::Permissions::new();
+ permissions.view_video = true;
+ let s = Server::new(Some(permissions));
+ let cli = reqwest::Client::new();
+ let resp = cli
+ .get(&format!(
+ "{}/api/cameras/{}/main/view.mp4",
+ &s.base_url, s.db.test_camera_uuid
+ ))
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST);
+ }
+
+ #[test]
+ #[rustfmt::skip]
+ fn test_segments() {
+ testutil::init();
+ assert_eq!(
+ Segments { ids: 1..2, open_id: None, start_time: 0, end_time: None },
+ Segments::from_str("1").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..2, open_id: Some(42), start_time: 0, end_time: None },
+ Segments::from_str("1@42").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..2, open_id: None, start_time: 26, end_time: None },
+ Segments::from_str("1.26-").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..2, open_id: Some(42), start_time: 26, end_time: None },
+ Segments::from_str("1@42.26-").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..2, open_id: None, start_time: 0, end_time: Some(42) },
+ Segments::from_str("1.-42").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..2, open_id: None, start_time: 26, end_time: Some(42) },
+ Segments::from_str("1.26-42").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..6, open_id: None, start_time: 0, end_time: None },
+ Segments::from_str("1-5").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..6, open_id: None, start_time: 26, end_time: None },
+ Segments::from_str("1-5.26-").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..6, open_id: None, start_time: 0, end_time: Some(42) },
+ Segments::from_str("1-5.-42").unwrap()
+ );
+ assert_eq!(
+ Segments { ids: 1..6, open_id: None, start_time: 26, end_time: Some(42) },
+ Segments::from_str("1-5.26-42").unwrap()
+ );
+ }
+}