From bae45a0855c457a3af197704229654f1f603faae Mon Sep 17 00:00:00 2001 From: Scott Lamb Date: Thu, 28 Oct 2021 13:23:49 -0700 Subject: [PATCH] move /view.{mp4,m4s} to their own file --- server/src/web/mod.rs | 338 +-------------------------------------- server/src/web/view.rs | 355 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 361 insertions(+), 332 deletions(-) create mode 100644 server/src/web/view.rs 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() + ); + } +}