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 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<HeaderValue>,
}
/// 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<db::Database>,
dirs_by_stream_id: Arc<::fnv::FnvHashMap<i32, Arc<dir::SampleFileDir>>>)
@ -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<HeaderValue>,
}
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<SystemTime> { Some(self.0.last_modified) }
fn etag(&self) -> Option<HeaderValue> { Some(self.0.etag.clone()) }

View File

@ -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()))?;