move /view.{mp4,m4s} to their own file

This commit is contained in:
Scott Lamb 2021-10-28 13:23:49 -07:00
parent 87f9736d80
commit bae45a0855
2 changed files with 361 additions and 332 deletions

View File

@ -5,6 +5,7 @@
mod live; mod live;
mod path; mod path;
mod static_file; mod static_file;
mod view;
use self::path::Path; use self::path::Path;
use crate::body::Body; use crate::body::Body;
@ -23,16 +24,10 @@ use http::method::Method;
use http::{status::StatusCode, Request, Response}; use http::{status::StatusCode, Request, Response};
use http_serve::dir::FsDir; use http_serve::dir::FsDir;
use hyper::body::Bytes; use hyper::body::Bytes;
use log::{debug, info, trace, warn}; use log::{debug, info, warn};
use memchr::memchr; 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::convert::TryFrom;
use std::net::IpAddr; use std::net::IpAddr;
use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use url::form_urlencoded; use url::form_urlencoded;
use uuid::Uuid; use uuid::Uuid;
@ -92,70 +87,6 @@ fn from_base_error(err: base::Error) -> Response<Body> {
plain_response(status_code, err.to_string()) plain_response(status_code, err.to_string())
} }
#[derive(Debug, Eq, PartialEq)]
struct Segments {
ids: Range<i32>,
open_id: Option<u32>,
start_time: i64,
end_time: Option<i64>,
}
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<i32>.
// Note that END_ID is inclusive, but Ranges are half-open.
let (i, ids) = map(
tuple((num::<i32>(), opt(preceded(tag("-"), num::<i32>())))),
|(start, end)| start..end.unwrap_or(start) + 1,
)(i)?;
// Parse [@OPEN_ID] into Option<u32>.
let (i, open_id) = opt(preceded(tag("@"), num::<u32>()))(i)?;
// Parse [.[REL_START_TIME]-[REL_END_TIME]] into (i64, Option<i64>).
let (i, (start_time, end_time)) = map(
opt(preceded(
tag("."),
tuple((opt(num::<i64>()), tag("-"), opt(num::<i64>()))),
)),
|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<Self, Self::Err> {
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 { struct Caller {
permissions: db::Permissions, permissions: db::Permissions,
user: Option<json::ToplevelUser>, user: Option<json::ToplevelUser>,
@ -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<hyper::Body>, caller: Caller, id: i32) -> ResponseResult { async fn user(&self, req: Request<hyper::Body>, caller: Caller, id: i32) -> ResponseResult {
if caller.user.map(|u| u.id) != Some(id) { if caller.user.map(|u| u.id) != Some(id) {
bail_t!(Unauthenticated, "must be authenticated as supplied user"); bail_t!(Unauthenticated, "must be authenticated as supplied user");
@ -1052,24 +792,22 @@ fn encode_sid(sid: db::RawSessionId, flags: i32) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Segments;
use db::testutil::{self, TestDb}; use db::testutil::{self, TestDb};
use futures::future::FutureExt; use futures::future::FutureExt;
use log::info; use log::info;
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
struct Server { pub(super) struct Server {
db: TestDb<base::clock::RealClocks>, pub(super) db: TestDb<base::clock::RealClocks>,
base_url: String, pub(super) base_url: String,
//test_camera_uuid: Uuid, //test_camera_uuid: Uuid,
handle: Option<::std::thread::JoinHandle<()>>, handle: Option<::std::thread::JoinHandle<()>>,
shutdown_tx: Option<futures::channel::oneshot::Sender<()>>, shutdown_tx: Option<futures::channel::oneshot::Sender<()>>,
} }
impl Server { impl Server {
fn new(allow_unauthenticated_permissions: Option<db::Permissions>) -> Server { pub(super) fn new(allow_unauthenticated_permissions: Option<db::Permissions>) -> Server {
let db = TestDb::new(base::clock::RealClocks {}); let db = TestDb::new(base::clock::RealClocks {});
let (shutdown_tx, shutdown_rx) = futures::channel::oneshot::channel::<()>(); let (shutdown_tx, shutdown_rx) = futures::channel::oneshot::channel::<()>();
let service = Arc::new( 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] #[tokio::test]
async fn unauthorized_without_cookie() { async fn unauthorized_without_cookie() {
testutil::init(); testutil::init();
@ -1333,24 +1025,6 @@ mod tests {
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); 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] #[test]
fn encode_sid() { fn encode_sid() {
use super::encode_sid; use super::encode_sid;

355
server/src/web/view.rs Normal file
View File

@ -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<i32>,
open_id: Option<u32>,
start_time: i64,
end_time: Option<i64>,
}
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<i32>.
// Note that END_ID is inclusive, but Ranges are half-open.
let (i, ids) = map(
tuple((num::<i32>(), opt(preceded(tag("-"), num::<i32>())))),
|(start, end)| start..end.unwrap_or(start) + 1,
)(i)?;
// Parse [@OPEN_ID] into Option<u32>.
let (i, open_id) = opt(preceded(tag("@"), num::<u32>()))(i)?;
// Parse [.[REL_START_TIME]-[REL_END_TIME]] into (i64, Option<i64>).
let (i, (start_time, end_time)) = map(
opt(preceded(
tag("."),
tuple((opt(num::<i64>()), tag("-"), opt(num::<i64>()))),
)),
|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<Self, Self::Err> {
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()
);
}
}