use crate::action::{Error, Result};
use crate::ctx::{BoxFuture, Ctx};
use std::io::{Cursor, Read, Seek};
use std::path::Path;
pub const MAX_MEDIA_ID: usize = 128;
pub const MEDIA_CHUNK: usize = 64 * 1024;
pub const MAX_POSITION_SECONDS: f64 = 10_000_000.0;
pub trait MediaReader: Read + Seek + Send + 'static {}
impl<T: Read + Seek + Send + 'static> MediaReader for T {}
pub struct Media {
pub(crate) reader: Box<dyn MediaReader>,
pub(crate) len: u64,
pub(crate) content_type: String,
pub(crate) etag: Option<String>,
}
impl Media {
pub fn new(reader: impl MediaReader, len: u64, content_type: impl Into<String>) -> Self {
Media {
reader: Box::new(reader),
len,
content_type: content_type.into(),
etag: None,
}
}
pub fn bytes<T: AsRef<[u8]> + Send + 'static>(
data: T,
content_type: impl Into<String>,
) -> Self {
let len = data.as_ref().len() as u64;
Media::new(Cursor::new(data), len, content_type)
}
pub fn file(path: impl AsRef<Path>) -> std::io::Result<Self> {
let path = path.as_ref();
let file = std::fs::File::open(path)?;
let meta = file.metadata()?;
if !meta.is_file() {
return Err(std::io::Error::other("not a file"));
}
let modified = meta
.modified()
.ok()
.and_then(|m| {
m.duration_since(std::time::UNIX_EPOCH)
.ok()
})
.map(|d| d.as_secs())
.unwrap_or(0);
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
Ok(Media::new(file, meta.len(), media_type(name))
.etag(format!("{:x}-{modified:x}", meta.len())))
}
pub fn etag(mut self, tag: impl Into<String>) -> Self {
let tag: String = tag
.into()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
.collect();
self.etag = (!tag.is_empty()).then_some(tag);
self
}
pub fn len(&self) -> u64 {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn content_type(&self) -> &str {
&self.content_type
}
}
pub trait MediaSource: Send + Sync + 'static {
fn open(&self, ctx: Ctx, id: String) -> BoxFuture<Result<Media>>;
fn position(&self, _ctx: Ctx, _id: String) -> BoxFuture<Result<Option<f64>>> {
Box::pin(async { Err(Error::NotFound) })
}
fn save_position(&self, _ctx: Ctx, _id: String, _seconds: f64) -> BoxFuture<Result<()>> {
Box::pin(async { Err(Error::NotFound) })
}
}
#[derive(Clone)]
pub struct MediaEntry {
pub name: &'static str,
pub permission: &'static str,
pub source: std::sync::Arc<dyn MediaSource>,
pub public_cache: Option<u32>,
}
pub const MAX_PUBLIC_MEDIA_CACHE: u32 = 31_536_000;
pub fn valid_media_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_MEDIA_ID
&& !id.starts_with('.')
&& !id.contains("..")
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
}
pub fn media_type(name: &str) -> &'static str {
let ext = name
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
"mp4" | "m4v" => "video/mp4",
"webm" => "video/webm",
"ogv" => "video/ogg",
"mov" => "video/quicktime",
"ts" => "video/mp2t",
"m4s" => "video/iso.segment",
"mp3" => "audio/mpeg",
"m4a" | "aac" => "audio/mp4",
"oga" | "ogg" | "opus" => "audio/ogg",
"wav" => "audio/wav",
"flac" => "audio/flac",
"weba" => "audio/webm",
"vtt" => "text/vtt; charset=utf-8",
"m3u8" => "application/vnd.apple.mpegurl",
"mpd" => "application/dash+xml",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"webp" => "image/webp",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
pub(crate) fn inline_type(content_type: &str) -> bool {
let t = content_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
(t.starts_with("audio/")
|| t.starts_with("video/")
|| (t.starts_with("image/") && t != "image/svg+xml"))
|| matches!(
t.as_str(),
"text/vtt"
| "application/vnd.apple.mpegurl"
| "application/x-mpegurl"
| "application/dash+xml"
| "application/mp4"
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ByteRange {
Full,
Part(u64, u64),
Unsatisfiable,
}
impl ByteRange {
pub fn parse(header: Option<&str>, len: u64) -> ByteRange {
let Some(spec) = header.map(str::trim) else {
return ByteRange::Full;
};
let Some(spec) = spec.strip_prefix("bytes=") else {
return ByteRange::Full;
};
if spec.contains(',') {
return ByteRange::Full;
}
let Some((a, b)) = spec.trim().split_once('-') else {
return ByteRange::Unsatisfiable;
};
let num = |s: &str| {
(!s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()))
.then(|| s.parse::<u64>().ok())
.flatten()
};
let (a, b) = (a.trim(), b.trim());
match (a.is_empty(), num(a), num(b)) {
(true, _, Some(n)) if n > 0 && len > 0 => {
ByteRange::Part(len.saturating_sub(n), len - 1)
}
(false, Some(start), None) if b.is_empty() && start < len => {
ByteRange::Part(start, len - 1)
}
(false, Some(start), Some(end)) if start <= end && start < len => {
ByteRange::Part(start, end.min(len - 1))
}
_ => ByteRange::Unsatisfiable,
}
}
}