add a url for getting debug info about a .mp4 file

and add a unit test of path decoding along the way
This commit is contained in:
Scott Lamb 2018-12-29 13:06:44 -06:00
parent 1123adec5d
commit eb8a51aecb
4 changed files with 169 additions and 75 deletions

View File

@ -350,7 +350,7 @@ pub struct Camera {
pub streams: [Option<i32>; 2], pub streams: [Option<i32>; 2],
} }
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum StreamType { MAIN, SUB } pub enum StreamType { MAIN, SUB }
impl StreamType { impl StreamType {

View File

@ -302,6 +302,11 @@ Example request URI to retrieve recording id 1, skipping its first 26
TODO: error behavior on missing segment. It should be a 404, likely with an TODO: error behavior on missing segment. It should be a 404, likely with an
`application/json` body describing what portion if any (still) exists. `application/json` body describing what portion if any (still) exists.
### `/api/cameras/<uuid>/<stream>/view.mp4.txt`
A GET returns a `text/plain` debugging string for the `.mp4` generated by the
same URL minus the `.txt` suffix.
### `/api/cameras/<uuid>/<stream>/view.m4s` ### `/api/cameras/<uuid>/<stream>/view.m4s`
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions
@ -328,12 +333,22 @@ recording segment for several reasons:
than one video sample entry, so a `.m4s` that uses more than one video than one video sample entry, so a `.m4s` that uses more than one video
sample entry can't be used. sample entry can't be used.
### `/api/cameras/<uuid>/<stream>/view.m4s.txt`
A GET returns a `text/plain` debugging string for the `.mp4` generated by the
same URL minus the `.txt` suffix.
### `/api/init/<sha1>.mp4` ### `/api/init/<sha1>.mp4`
A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions A GET returns a `.mp4` suitable for use as a [HTML5 Media Source Extensions
initialization segment][init-segment]. The MIME type will be `video/mp4`, with initialization segment][init-segment]. The MIME type will be `video/mp4`, with
a `codecs` parameter as specified in [RFC 6381][rfc-6381]. a `codecs` parameter as specified in [RFC 6381][rfc-6381].
### `/api/init/<sha1>.mp4.txt`
A GET returns a `text/plain` debugging string for the `.mp4` generated by the
same URL minus the `.txt` suffix.
[media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments [media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments
[init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments [init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments
[rfc-6381]: https://tools.ietf.org/html/rfc6381 [rfc-6381]: https://tools.ietf.org/html/rfc6381

View File

@ -683,8 +683,8 @@ impl slices::Slice for Slice {
fn get_slices(ctx: &File) -> &Slices<Self> { &ctx.0.slices } fn get_slices(ctx: &File) -> &Slices<Self> { &ctx.0.slices }
} }
impl ::std::fmt::Debug for Slice { impl fmt::Debug for Slice {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
// Write an unpacked representation. Omit end(); Slices writes that part. // Write an unpacked representation. Omit end(); Slices writes that part.
write!(f, "{:?} {}", self.t(), self.p()) write!(f, "{:?} {}", self.t(), self.p())
} }
@ -1523,6 +1523,16 @@ impl http_serve::Entity for File {
} }
} }
impl fmt::Debug for File {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_struct("mp4::File")
.field("last_modified", &self.0.last_modified)
.field("etag", &self.0.etag)
.field("slices", &self.0.slices)
.finish()
}
}
/// Tests. There are two general strategies used to validate the resulting files: /// Tests. There are two general strategies used to validate the resulting files:
/// ///
/// * basic tests that ffmpeg can read the generated mp4s. This ensures compatibility with /// * basic tests that ffmpeg can read the generated mp4s. This ensures compatibility with

View File

@ -68,80 +68,89 @@ lazy_static! {
Regex::new(r"^(\d+)(-\d+)?(@\d+)?(?:\.(\d+)?-(\d+)?)?$").unwrap(); Regex::new(r"^(\d+)(-\d+)?(@\d+)?(?:\.(\d+)?-(\d+)?)?$").unwrap();
} }
#[derive(Debug)] #[derive(Debug, Eq, PartialEq)]
enum Path { enum Path {
TopLevel, // "/api/" TopLevel, // "/api/"
Request, // "/api/request" Request, // "/api/request"
InitSegment([u8; 20]), // "/api/init/<sha1>.mp4" InitSegment([u8; 20], bool), // "/api/init/<sha1>.mp4{.txt}"
Camera(Uuid), // "/api/cameras/<uuid>/" Camera(Uuid), // "/api/cameras/<uuid>/"
StreamRecordings(Uuid, db::StreamType), // "/api/cameras/<uuid>/<type>/recordings" StreamRecordings(Uuid, db::StreamType), // "/api/cameras/<uuid>/<type>/recordings"
StreamViewMp4(Uuid, db::StreamType), // "/api/cameras/<uuid>/<type>/view.mp4" StreamViewMp4(Uuid, db::StreamType, bool), // "/api/cameras/<uuid>/<type>/view.mp4{.txt}"
StreamViewMp4Segment(Uuid, db::StreamType), // "/api/cameras/<uuid>/<type>/view.m4s" StreamViewMp4Segment(Uuid, db::StreamType, bool), // "/api/cameras/<uuid>/<type>/view.m4s{.txt}"
Login, // "/api/login" Login, // "/api/login"
Logout, // "/api/logout" Logout, // "/api/logout"
Static, // (anything that doesn't start with "/api/") Static, // (anything that doesn't start with "/api/")
NotFound, NotFound,
} }
fn decode_path(path: &str) -> Path { impl Path {
if !path.starts_with("/api/") { fn decode(path: &str) -> Self {
return Path::Static; if !path.starts_with("/api/") {
} return Path::Static;
let path = &path["/api".len()..]; }
if path == "/" { let path = &path["/api".len()..];
return Path::TopLevel; if path == "/" {
} return Path::TopLevel;
match path { }
"/request" => return Path::Request, match path {
"/login" => return Path::Login, "/request" => return Path::Request,
"/logout" => return Path::Logout, "/login" => return Path::Login,
_ => {}, "/logout" => return Path::Logout,
}; _ => {},
if path.starts_with("/init/") { };
if path.len() != 50 || !path.ends_with(".mp4") { if path.starts_with("/init/") {
let (debug, path) = if path.ends_with(".txt") {
(true, &path[0 .. path.len() - 4])
} else {
(false, path)
};
if path.len() != 50 || !path.ends_with(".mp4") {
return Path::NotFound;
}
if let Ok(sha1) = strutil::dehex(&path.as_bytes()[6..46]) {
return Path::InitSegment(sha1, debug);
}
return Path::NotFound; return Path::NotFound;
} }
if let Ok(sha1) = strutil::dehex(&path.as_bytes()[6..46]) { if !path.starts_with("/cameras/") {
return Path::InitSegment(sha1); return Path::NotFound;
} }
return Path::NotFound; let path = &path["/cameras/".len()..];
} let slash = match path.find('/') {
if !path.starts_with("/cameras/") { None => { return Path::NotFound; },
return Path::NotFound; Some(s) => s,
} };
let path = &path["/cameras/".len()..]; let uuid = &path[0 .. slash];
let slash = match path.find('/') { let path = &path[slash+1 .. ];
None => { return Path::NotFound; },
Some(s) => s,
};
let uuid = &path[0 .. slash];
let path = &path[slash+1 .. ];
// TODO(slamb): require uuid to be in canonical format. // TODO(slamb): require uuid to be in canonical format.
let uuid = match Uuid::parse_str(uuid) { let uuid = match Uuid::parse_str(uuid) {
Ok(u) => u, Ok(u) => u,
Err(_) => { return Path::NotFound }, Err(_) => { return Path::NotFound },
}; };
if path.is_empty() { if path.is_empty() {
return Path::Camera(uuid); return Path::Camera(uuid);
} }
let slash = match path.find('/') { let slash = match path.find('/') {
None => { return Path::NotFound; }, None => { return Path::NotFound; },
Some(s) => s, Some(s) => s,
}; };
let (type_, path) = path.split_at(slash); let (type_, path) = path.split_at(slash);
let type_ = match db::StreamType::parse(type_) { let type_ = match db::StreamType::parse(type_) {
None => { return Path::NotFound; }, None => { return Path::NotFound; },
Some(t) => t, Some(t) => t,
}; };
match path { match path {
"/recordings" => Path::StreamRecordings(uuid, type_), "/recordings" => Path::StreamRecordings(uuid, type_),
"/view.mp4" => Path::StreamViewMp4(uuid, type_), "/view.mp4" => Path::StreamViewMp4(uuid, type_, false),
"/view.m4s" => Path::StreamViewMp4Segment(uuid, type_), "/view.mp4.txt" => Path::StreamViewMp4(uuid, type_, true),
_ => Path::NotFound, "/view.m4s" => Path::StreamViewMp4Segment(uuid, type_, false),
"/view.m4s.txt" => Path::StreamViewMp4Segment(uuid, type_, true),
_ => Path::NotFound,
}
} }
} }
@ -349,7 +358,8 @@ impl ServiceInner {
Ok(resp) Ok(resp)
} }
fn init_segment(&self, sha1: [u8; 20], req: &Request<::hyper::Body>) -> ResponseResult { fn init_segment(&self, sha1: [u8; 20], debug: bool, req: &Request<::hyper::Body>)
-> ResponseResult {
let mut builder = mp4::FileBuilder::new(mp4::Type::InitSegment); let mut builder = mp4::FileBuilder::new(mp4::Type::InitSegment);
let db = self.db.lock(); let db = self.db.lock();
for ent in db.video_sample_entries_by_id().values() { for ent in db.video_sample_entries_by_id().values() {
@ -357,14 +367,18 @@ impl ServiceInner {
builder.append_video_sample_entry(ent.clone()); builder.append_video_sample_entry(ent.clone());
let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone()) let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone())
.map_err(from_base_error)?; .map_err(from_base_error)?;
return Ok(http_serve::serve(mp4, req)); if debug {
return Ok(plain_response(StatusCode::OK, format!("{:#?}", mp4)));
} else {
return Ok(http_serve::serve(mp4, req));
}
} }
} }
Err(not_found("no such init segment")) Err(not_found("no such init segment"))
} }
fn stream_view_mp4(&self, req: &Request<::hyper::Body>, uuid: Uuid, fn stream_view_mp4(&self, req: &Request<::hyper::Body>, uuid: Uuid,
stream_type_: db::StreamType, mp4_type_: mp4::Type) stream_type_: db::StreamType, mp4_type_: mp4::Type, debug: bool)
-> ResponseResult { -> ResponseResult {
let stream_id = { let stream_id = {
let db = self.db.lock(); let db = self.db.lock();
@ -468,6 +482,9 @@ impl ServiceInner {
} }
let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone()) let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone())
.map_err(from_base_error)?; .map_err(from_base_error)?;
if debug {
return Ok(plain_response(StatusCode::OK, format!("{:#?}", mp4)));
}
Ok(http_serve::serve(mp4, req)) Ok(http_serve::serve(mp4, req))
} }
@ -805,7 +822,7 @@ impl ::hyper::service::Service for Service {
return wrap(is_private, future::result(r)) return wrap(is_private, future::result(r))
} }
let p = decode_path(req.uri().path()); let p = Path::decode(req.uri().path());
let require_auth = self.0.require_auth && match p { let require_auth = self.0.require_auth && match p {
Path::NotFound | Path::Request | Path::Login | Path::Logout | Path::Static => false, Path::NotFound | Path::Request | Path::Login | Path::Logout | Path::Static => false,
_ => true, _ => true,
@ -820,18 +837,19 @@ impl ::hyper::service::Service for Service {
plain_response(StatusCode::UNAUTHORIZED, "unauthorized"))); plain_response(StatusCode::UNAUTHORIZED, "unauthorized")));
} }
match p { match p {
Path::InitSegment(sha1) => wrap_r(true, self.0.init_segment(sha1, &req)), Path::InitSegment(sha1, debug) => wrap_r(true, self.0.init_segment(sha1, debug, &req)),
Path::TopLevel => wrap_r(true, self.0.top_level(&req, session)), Path::TopLevel => wrap_r(true, self.0.top_level(&req, session)),
Path::Request => wrap_r(true, self.0.request(&req)), Path::Request => wrap_r(true, self.0.request(&req)),
Path::Camera(uuid) => wrap_r(true, self.0.camera(&req, uuid)), Path::Camera(uuid) => wrap_r(true, self.0.camera(&req, uuid)),
Path::StreamRecordings(uuid, type_) => { Path::StreamRecordings(uuid, type_) => {
wrap_r(true, self.0.stream_recordings(&req, uuid, type_)) wrap_r(true, self.0.stream_recordings(&req, uuid, type_))
}, },
Path::StreamViewMp4(uuid, type_) => { Path::StreamViewMp4(uuid, type_, debug) => {
wrap_r(true, self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::Normal)) wrap_r(true, self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::Normal, debug))
}, },
Path::StreamViewMp4Segment(uuid, type_) => { Path::StreamViewMp4Segment(uuid, type_, debug) => {
wrap_r(true, self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::MediaSegment)) wrap_r(true, self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::MediaSegment,
debug))
}, },
Path::NotFound => wrap(true, future::err(not_found("path not understood"))), Path::NotFound => wrap(true, future::err(not_found("path not understood"))),
Path::Login => wrap(true, self.with_form_body(req).and_then({ Path::Login => wrap(true, self.with_form_body(req).and_then({
@ -939,6 +957,57 @@ mod tests {
} }
} }
#[test]
fn paths() {
use super::Path;
use uuid::Uuid;
let cam_uuid = Uuid::parse_str("35144640-ff1e-4619-b0d5-4c74c185741c").unwrap();
assert_eq!(Path::decode("/foo"), Path::Static);
assert_eq!(Path::decode("/api/"), Path::TopLevel);
assert_eq!(Path::decode("/api/init/07cec464126825088ea86a07eddd6a00afa71559.mp4"),
Path::InitSegment([0x07, 0xce, 0xc4, 0x64, 0x12, 0x68, 0x25, 0x08, 0x8e, 0xa8,
0x6a, 0x07, 0xed, 0xdd, 0x6a, 0x00, 0xaf, 0xa7, 0x15, 0x59],
false));
assert_eq!(Path::decode("/api/init/07cec464126825088ea86a07eddd6a00afa71559.mp4.txt"),
Path::InitSegment([0x07, 0xce, 0xc4, 0x64, 0x12, 0x68, 0x25, 0x08, 0x8e, 0xa8,
0x6a, 0x07, 0xed, 0xdd, 0x6a, 0x00, 0xaf, 0xa7, 0x15, 0x59],
true));
assert_eq!(Path::decode("/api/init/000000000000000000000000000000000000000x.mp4"),
Path::NotFound); // non-hexadigit
assert_eq!(Path::decode("/api/init/000000000000000000000000000000000000000.mp4"),
Path::NotFound); // too short
assert_eq!(Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/"),
Path::Camera(cam_uuid));
assert_eq!(Path::decode("/api/cameras/asdf/"), Path::NotFound);
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/main/recordings"),
Path::StreamRecordings(cam_uuid, db::StreamType::MAIN));
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/sub/recordings"),
Path::StreamRecordings(cam_uuid, db::StreamType::SUB));
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/junk/recordings"),
Path::NotFound);
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/main/view.mp4"),
Path::StreamViewMp4(cam_uuid, db::StreamType::MAIN, false));
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/main/view.mp4.txt"),
Path::StreamViewMp4(cam_uuid, db::StreamType::MAIN, true));
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/main/view.m4s"),
Path::StreamViewMp4Segment(cam_uuid, db::StreamType::MAIN, false));
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/main/view.m4s.txt"),
Path::StreamViewMp4Segment(cam_uuid, db::StreamType::MAIN, true));
assert_eq!(
Path::decode("/api/cameras/35144640-ff1e-4619-b0d5-4c74c185741c/main/junk"),
Path::NotFound);
assert_eq!(Path::decode("/api/login"), Path::Login);
assert_eq!(Path::decode("/api/logout"), Path::Logout);
assert_eq!(Path::decode("/api/junk"), Path::NotFound);
}
#[test] #[test]
fn test_segments() { fn test_segments() {
testutil::init(); testutil::init();