mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-04-20 18:54:00 -04:00
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:
parent
dd3c3f2f84
commit
f7da085335
26
src/mp4.rs
26
src/mp4.rs
@ -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()) }
|
||||||
|
24
src/web.rs
24
src/web.rs
@ -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()))?;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user