diff --git a/src/mp4.rs b/src/mp4.rs index 5eb8400..74c0627 100644 --- a/src/mp4.rs +++ b/src/mp4.rs @@ -95,6 +95,7 @@ use reffers::ARefss; use crate::slices::{self, Slices}; use smallvec::SmallVec; use std::cell::UnsafeCell; +use std::convert::TryFrom; use std::cmp; use std::fmt; use std::io; @@ -104,7 +105,7 @@ use std::sync::Arc; use std::time::SystemTime; /// This value should be incremented any time a change is made to this file that causes different -/// bytes to be output for a particular set of `Mp4Builder` options. Incrementing this value will +/// bytes to be output for a particular set of `FileBuilder` options. Incrementing this value will /// cause the etag to change as well. const FORMAT_VERSION: [u8; 1] = [0x06]; @@ -551,6 +552,7 @@ pub struct FileBuilder { body: BodyState, type_: Type, include_timestamp_subtitle_track: bool, + content_disposition: Option, } /// The portion of `FileBuilder` which is mutated while building the body of the file. @@ -711,7 +713,7 @@ macro_rules! write_length { }} } -#[derive(PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Type { Normal, InitSegment, @@ -734,6 +736,7 @@ impl FileBuilder { }, type_: type_, include_timestamp_subtitle_track: false, + content_disposition: None, } } @@ -773,6 +776,13 @@ impl FileBuilder { Ok(()) } + pub fn set_filename(&mut self, filename: &str) -> Result<(), Error> { + self.content_disposition = + Some(HeaderValue::try_from(format!("attachment; filename=\"{}\"", filename)) + .err_kind(ErrorKind::InvalidArgument)?); + Ok(()) + } + /// Builds the `File`, consuming the builder. pub fn build(mut self, db: Arc, dirs_by_stream_id: Arc<::fnv::FnvHashMap>>) @@ -784,6 +794,10 @@ impl FileBuilder { if self.include_timestamp_subtitle_track { etag.update(b":ts:").err_kind(ErrorKind::Internal)?; } + if let Some(cd) = self.content_disposition.as_ref() { + etag.update(b":cd:").err_kind(ErrorKind::Internal)?; + etag.update(cd.as_bytes()).err_kind(ErrorKind::Internal)?; + } match self.type_ { Type::Normal => {}, Type::InitSegment => etag.update(b":init:").err_kind(ErrorKind::Internal)?, @@ -884,8 +898,9 @@ impl FileBuilder { video_sample_entries: self.video_sample_entries, initial_sample_byte_pos, last_modified, - etag: HeaderValue::from_str(&format!("\"{}\"", &strutil::hex(&etag))) + etag: HeaderValue::try_from(format!("\"{}\"", &strutil::hex(&etag))) .expect("hex string should be valid UTF-8"), + content_disposition: self.content_disposition, }))) } @@ -1433,6 +1448,7 @@ struct FileInner { initial_sample_byte_pos: u64, last_modified: SystemTime, etag: HeaderValue, + content_disposition: Option, } impl FileInner { @@ -1513,6 +1529,10 @@ impl http_serve::Entity for File { mime.extend_from_slice(b"\""); hdrs.insert(http::header::CONTENT_TYPE, http::header::HeaderValue::from_maybe_shared(mime.freeze()).unwrap()); + + if let Some(cd) = self.0.content_disposition.as_ref() { + hdrs.insert(http::header::CONTENT_DISPOSITION, cd.clone()); + } } fn last_modified(&self) -> Option { Some(self.0.last_modified) } fn etag(&self) -> Option { Some(self.0.etag.clone()) } diff --git a/src/web.rs b/src/web.rs index 4c4ee4b..40ee2c0 100644 --- a/src/web.rs +++ b/src/web.rs @@ -399,16 +399,20 @@ impl ServiceInner { if !caller.permissions.view_video { return Err(plain_response(StatusCode::UNAUTHORIZED, "view_video required")); } - let stream_id = { + 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.streams[stream_type.index()] + 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)))? + 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()) { @@ -465,6 +469,10 @@ impl ServiceInner { let times = start as i32 .. end as i32; debug!("...appending recording {} with times {:?} \ (out of dur {})", r.id, times, d); + if start_time_for_filename.is_none() { + start_time_for_filename = + Some(r.start + recording::Duration(start)); + } builder.append(&db, r, start as i32 .. end as i32)?; } else { debug!("...skipping recording {} dur {}", r.id, d); @@ -499,6 +507,14 @@ impl ServiceInner { } }; } + 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 { @@ -572,7 +588,7 @@ impl ServiceInner { let flags = (auth::SessionFlags::HttpOnly as i32) | (auth::SessionFlags::SameSite as i32) | (auth::SessionFlags::SameSiteStrict as i32) | - if is_secure { (auth::SessionFlags::Secure as i32) } else { 0 }; + if is_secure { auth::SessionFlags::Secure as i32 } else { 0 }; let (sid, _) = l.login_by_password(authreq, &r.username, r.password, Some(domain), flags) .map_err(|e| plain_response(StatusCode::UNAUTHORIZED, e.to_string()))?;