mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2024-12-26 23:25:55 -05:00
move /view.{mp4,m4s} to their own file
This commit is contained in:
parent
87f9736d80
commit
bae45a0855
@ -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
355
server/src/web/view.rs
Normal 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user