give downloaded .mp4s a useful filename

This is effective both for Chrome's "Save As" dialog and for curl -OJ.
It makes the filename like 20190717135519-driveway-main.mp4 rather than
view.mp4 (Chrome) or view.mp4?s=33-36&ts=true (Curl).
This commit is contained in:
Scott Lamb 2020-02-16 23:16:19 -08:00
parent dd3c3f2f84
commit f7da085335
2 changed files with 43 additions and 7 deletions

View File

@ -95,6 +95,7 @@ use reffers::ARefss;
use crate::slices::{self, Slices}; use crate::slices::{self, Slices};
use smallvec::SmallVec; use smallvec::SmallVec;
use std::cell::UnsafeCell; use std::cell::UnsafeCell;
use std::convert::TryFrom;
use std::cmp; use std::cmp;
use std::fmt; use std::fmt;
use std::io; use std::io;
@ -104,7 +105,7 @@ use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
/// This value should be incremented any time a change is made to this file that causes different /// 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. /// cause the etag to change as well.
const FORMAT_VERSION: [u8; 1] = [0x06]; const FORMAT_VERSION: [u8; 1] = [0x06];
@ -551,6 +552,7 @@ pub struct FileBuilder {
body: BodyState, body: BodyState,
type_: Type, type_: Type,
include_timestamp_subtitle_track: bool, include_timestamp_subtitle_track: bool,
content_disposition: Option<HeaderValue>,
} }
/// The portion of `FileBuilder` which is mutated while building the body of the file. /// 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 { pub enum Type {
Normal, Normal,
InitSegment, InitSegment,
@ -734,6 +736,7 @@ impl FileBuilder {
}, },
type_: type_, type_: type_,
include_timestamp_subtitle_track: false, include_timestamp_subtitle_track: false,
content_disposition: None,
} }
} }
@ -773,6 +776,13 @@ impl FileBuilder {
Ok(()) 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. /// Builds the `File`, consuming the builder.
pub fn build(mut self, db: Arc<db::Database>, pub fn build(mut self, db: Arc<db::Database>,
dirs_by_stream_id: Arc<::fnv::FnvHashMap<i32, Arc<dir::SampleFileDir>>>) dirs_by_stream_id: Arc<::fnv::FnvHashMap<i32, Arc<dir::SampleFileDir>>>)
@ -784,6 +794,10 @@ impl FileBuilder {
if self.include_timestamp_subtitle_track { if self.include_timestamp_subtitle_track {
etag.update(b":ts:").err_kind(ErrorKind::Internal)?; 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_ { match self.type_ {
Type::Normal => {}, Type::Normal => {},
Type::InitSegment => etag.update(b":init:").err_kind(ErrorKind::Internal)?, Type::InitSegment => etag.update(b":init:").err_kind(ErrorKind::Internal)?,
@ -884,8 +898,9 @@ impl FileBuilder {
video_sample_entries: self.video_sample_entries, video_sample_entries: self.video_sample_entries,
initial_sample_byte_pos, initial_sample_byte_pos,
last_modified, 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"), .expect("hex string should be valid UTF-8"),
content_disposition: self.content_disposition,
}))) })))
} }
@ -1433,6 +1448,7 @@ struct FileInner {
initial_sample_byte_pos: u64, initial_sample_byte_pos: u64,
last_modified: SystemTime, last_modified: SystemTime,
etag: HeaderValue, etag: HeaderValue,
content_disposition: Option<HeaderValue>,
} }
impl FileInner { impl FileInner {
@ -1513,6 +1529,10 @@ impl http_serve::Entity for File {
mime.extend_from_slice(b"\""); mime.extend_from_slice(b"\"");
hdrs.insert(http::header::CONTENT_TYPE, hdrs.insert(http::header::CONTENT_TYPE,
http::header::HeaderValue::from_maybe_shared(mime.freeze()).unwrap()); 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<SystemTime> { Some(self.0.last_modified) } fn last_modified(&self) -> Option<SystemTime> { Some(self.0.last_modified) }
fn etag(&self) -> Option<HeaderValue> { Some(self.0.etag.clone()) } fn etag(&self) -> Option<HeaderValue> { Some(self.0.etag.clone()) }

View File

@ -399,16 +399,20 @@ impl ServiceInner {
if !caller.permissions.view_video { if !caller.permissions.view_video {
return Err(plain_response(StatusCode::UNAUTHORIZED, "view_video required")); return Err(plain_response(StatusCode::UNAUTHORIZED, "view_video required"));
} }
let stream_id = { let (stream_id, camera_name);
{
let db = self.db.lock(); let db = self.db.lock();
let camera = db.get_camera(uuid) let camera = db.get_camera(uuid)
.ok_or_else(|| plain_response(StatusCode::NOT_FOUND, .ok_or_else(|| plain_response(StatusCode::NOT_FOUND,
format!("no such camera {}", uuid)))?; 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, .ok_or_else(|| plain_response(StatusCode::NOT_FOUND,
format!("no such stream {}/{}", uuid, format!("no such stream {}/{}", uuid,
stream_type)))? stream_type)))?;
}; };
let mut start_time_for_filename = None;
let mut builder = mp4::FileBuilder::new(mp4_type); let mut builder = mp4::FileBuilder::new(mp4_type);
if let Some(q) = req.uri().query() { if let Some(q) = req.uri().query() {
for (key, value) in form_urlencoded::parse(q.as_bytes()) { for (key, value) in form_urlencoded::parse(q.as_bytes()) {
@ -465,6 +469,10 @@ impl ServiceInner {
let times = start as i32 .. end as i32; let times = start as i32 .. end as i32;
debug!("...appending recording {} with times {:?} \ debug!("...appending recording {} with times {:?} \
(out of dur {})", r.id, times, d); (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)?; builder.append(&db, r, start as i32 .. end as i32)?;
} else { } else {
debug!("...skipping recording {} dur {}", r.id, d); 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()) 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 { if debug {
@ -572,7 +588,7 @@ impl ServiceInner {
let flags = (auth::SessionFlags::HttpOnly as i32) | let flags = (auth::SessionFlags::HttpOnly as i32) |
(auth::SessionFlags::SameSite as i32) | (auth::SessionFlags::SameSite as i32) |
(auth::SessionFlags::SameSiteStrict 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), let (sid, _) = l.login_by_password(authreq, &r.username, r.password, Some(domain),
flags) flags)
.map_err(|e| plain_response(StatusCode::UNAUTHORIZED, e.to_string()))?; .map_err(|e| plain_response(StatusCode::UNAUTHORIZED, e.to_string()))?;