// 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() ); } }