Added video preview and http range support
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s
This commit is contained in:
Generated
+1
-1
@@ -3466,7 +3466,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "1.0.0"
|
version = "1.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
+22
-20
@@ -2285,26 +2285,28 @@ async fn serve_upload(
|
|||||||
None => return Html::new("404").into_response(),
|
None => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match crate::uploads::read_db_file(&media.file_path).await {
|
let range = request
|
||||||
Ok(data) => {
|
.headers()
|
||||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
.get("range")
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
.and_then(|value| value.to_str().ok())
|
||||||
"png" => "image/png",
|
.map(str::to_owned);
|
||||||
"heic" | "heif" => "image/heic",
|
|
||||||
"webp" => "image/webp",
|
match {
|
||||||
"mp4" => "video/mp4",
|
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||||
"mov" => "video/quicktime",
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
"avi" => "video/x-msvideo",
|
"png" => "image/png",
|
||||||
"mkv" => "video/x-matroska",
|
"heic" | "heif" => "image/heic",
|
||||||
"webm" => "video/webm",
|
"webp" => "image/webp",
|
||||||
_ => "application/octet-stream",
|
"mp4" => "video/mp4",
|
||||||
};
|
"mov" => "video/quicktime",
|
||||||
let body = cot::Body::fixed(data);
|
"avi" => "video/x-msvideo",
|
||||||
let mut resp = Response::new(body);
|
"mkv" => "video/x-matroska",
|
||||||
resp.headers_mut()
|
"webm" => "video/webm",
|
||||||
.insert("content-type", content_type.parse().unwrap());
|
_ => "application/octet-stream",
|
||||||
Ok(resp)
|
};
|
||||||
}
|
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
||||||
|
} {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "uploads",
|
target: "uploads",
|
||||||
|
|||||||
+23
-21
@@ -370,7 +370,7 @@ async fn submit_feedback(
|
|||||||
|
|
||||||
/// Serve media files for the client portal (no auth required, but only via token).
|
/// Serve media files for the client portal (no auth required, but only via token).
|
||||||
async fn portal_media(
|
async fn portal_media(
|
||||||
_request: Request,
|
request: Request,
|
||||||
db: Database,
|
db: Database,
|
||||||
Path((token, media_id)): Path<(String, i64)>,
|
Path((token, media_id)): Path<(String, i64)>,
|
||||||
) -> cot::Result<Response> {
|
) -> cot::Result<Response> {
|
||||||
@@ -394,26 +394,28 @@ async fn portal_media(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match crate::uploads::read_db_file(&media.file_path).await {
|
let range = request
|
||||||
Ok(data) => {
|
.headers()
|
||||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
.get("range")
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
.and_then(|value| value.to_str().ok())
|
||||||
"png" => "image/png",
|
.map(str::to_owned);
|
||||||
"heic" | "heif" => "image/heic",
|
|
||||||
"webp" => "image/webp",
|
match {
|
||||||
"mp4" => "video/mp4",
|
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
||||||
"mov" => "video/quicktime",
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
"avi" => "video/x-msvideo",
|
"png" => "image/png",
|
||||||
"mkv" => "video/x-matroska",
|
"heic" | "heif" => "image/heic",
|
||||||
"webm" => "video/webm",
|
"webp" => "image/webp",
|
||||||
_ => "application/octet-stream",
|
"mp4" => "video/mp4",
|
||||||
};
|
"mov" => "video/quicktime",
|
||||||
let body = cot::Body::fixed(data);
|
"avi" => "video/x-msvideo",
|
||||||
let mut resp = Response::new(body);
|
"mkv" => "video/x-matroska",
|
||||||
resp.headers_mut()
|
"webm" => "video/webm",
|
||||||
.insert("content-type", content_type.parse().unwrap());
|
_ => "application/octet-stream",
|
||||||
Ok(resp)
|
};
|
||||||
}
|
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
||||||
|
} {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "uploads",
|
target: "uploads",
|
||||||
|
|||||||
+146
@@ -1,5 +1,9 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use cot::response::Response;
|
||||||
|
use cot::{Body, StatusCode};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
||||||
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
||||||
|
|
||||||
@@ -53,6 +57,148 @@ pub async fn read_db_file(db_path: &str) -> std::io::Result<Vec<u8>> {
|
|||||||
tokio::fs::read(resolve_db_path(db_path)).await
|
tokio::fs::read(resolve_db_path(db_path)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ByteRange {
|
||||||
|
Full,
|
||||||
|
Partial { start: u64, end: u64 },
|
||||||
|
Unsatisfiable,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange {
|
||||||
|
let Some(value) = header else {
|
||||||
|
return ByteRange::Full;
|
||||||
|
};
|
||||||
|
let Some(spec) = value.strip_prefix("bytes=") else {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
};
|
||||||
|
if spec.contains(',') || file_len == 0 {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some((start, end)) = spec.split_once('-') else {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
};
|
||||||
|
if start.is_empty() {
|
||||||
|
let Ok(suffix_len) = end.parse::<u64>() else {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
};
|
||||||
|
if suffix_len == 0 {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
}
|
||||||
|
let start = file_len.saturating_sub(suffix_len);
|
||||||
|
return ByteRange::Partial {
|
||||||
|
start,
|
||||||
|
end: file_len - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(start) = start.parse::<u64>() else {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
};
|
||||||
|
if start >= file_len {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
}
|
||||||
|
let end = if end.is_empty() {
|
||||||
|
file_len - 1
|
||||||
|
} else {
|
||||||
|
let Ok(end) = end.parse::<u64>() else {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
};
|
||||||
|
end.min(file_len - 1)
|
||||||
|
};
|
||||||
|
if end < start {
|
||||||
|
return ByteRange::Unsatisfiable;
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteRange::Partial { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file into an HTTP response, honoring a single `Range: bytes=...` request.
|
||||||
|
pub async fn ranged_file_response(
|
||||||
|
db_path: &str,
|
||||||
|
content_type: &str,
|
||||||
|
range_header: Option<&str>,
|
||||||
|
) -> std::io::Result<Response> {
|
||||||
|
let path = resolve_db_path(db_path);
|
||||||
|
let mut file = tokio::fs::File::open(path).await?;
|
||||||
|
let file_len = file.metadata().await?.len();
|
||||||
|
let range = parse_byte_range(range_header, file_len);
|
||||||
|
|
||||||
|
let (status, body, content_range) = match range {
|
||||||
|
ByteRange::Full => {
|
||||||
|
let mut data = Vec::with_capacity(file_len as usize);
|
||||||
|
file.read_to_end(&mut data).await?;
|
||||||
|
(StatusCode::OK, data, None)
|
||||||
|
}
|
||||||
|
ByteRange::Partial { start, end } => {
|
||||||
|
let range_len = end - start + 1;
|
||||||
|
let mut data = vec![0; range_len as usize];
|
||||||
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||||
|
file.read_exact(&mut data).await?;
|
||||||
|
(
|
||||||
|
StatusCode::PARTIAL_CONTENT,
|
||||||
|
data,
|
||||||
|
Some(format!("bytes {start}-{end}/{file_len}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ByteRange::Unsatisfiable => {
|
||||||
|
let mut response = Response::new(Body::fixed(Vec::<u8>::new()));
|
||||||
|
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("accept-ranges", "bytes".parse().unwrap());
|
||||||
|
response.headers_mut().insert(
|
||||||
|
"content-range",
|
||||||
|
format!("bytes */{file_len}").parse().unwrap(),
|
||||||
|
);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_len = body.len();
|
||||||
|
let mut response = Response::new(Body::fixed(body));
|
||||||
|
*response.status_mut() = status;
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-type", content_type.parse().unwrap());
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("accept-ranges", "bytes".parse().unwrap());
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-length", content_len.to_string().parse().unwrap());
|
||||||
|
if let Some(content_range) = content_range {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("content-range", content_range.parse().unwrap());
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> {
|
pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> {
|
||||||
tokio::fs::remove_file(resolve_db_path(db_path)).await
|
tokio::fs::remove_file(resolve_db_path(db_path)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ByteRange, parse_byte_range};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_byte_ranges() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_byte_range(Some("bytes=10-19"), 100),
|
||||||
|
ByteRange::Partial { start: 10, end: 19 }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_byte_range(Some("bytes=90-"), 100),
|
||||||
|
ByteRange::Partial { start: 90, end: 99 }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_byte_range(Some("bytes=-10"), 100),
|
||||||
|
ByteRange::Partial { start: 90, end: 99 }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_byte_range(Some("bytes=100-"), 100),
|
||||||
|
ByteRange::Unsatisfiable
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,10 @@
|
|||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="video">
|
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="video">
|
||||||
<div class="video-thumb">🎬</div>
|
<div class="video-thumb">
|
||||||
|
<video src="/admin/uploads/{{ item.media.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||||
|
<span class="video-play">▶</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
@@ -73,13 +76,27 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.media-card .video-thumb {
|
.media-card .video-thumb {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
display: flex;
|
background: #111;
|
||||||
align-items: center;
|
}
|
||||||
justify-content: center;
|
.media-card .video-thumb video {
|
||||||
font-size: 3rem;
|
width: 100%;
|
||||||
background: #f0f0f0;
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.media-card .video-play {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 1px 5px #000;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.media-info {
|
.media-info {
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
|
|||||||
@@ -118,7 +118,10 @@
|
|||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
|
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
|
||||||
<div class="video-thumb-sm">🎬</div>
|
<div class="video-thumb-sm">
|
||||||
|
<video src="/admin/uploads/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||||
|
<span class="video-play">▶</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if let Some(cap) = m.caption.as_deref() %}
|
{% if let Some(cap) = m.caption.as_deref() %}
|
||||||
@@ -236,13 +239,27 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.visit-media-item .video-thumb-sm {
|
.visit-media-item .video-thumb-sm {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
display: flex;
|
background: #111;
|
||||||
align-items: center;
|
}
|
||||||
justify-content: center;
|
.visit-media-item .video-thumb-sm video {
|
||||||
font-size: 2rem;
|
width: 100%;
|
||||||
background: #f0f0f0;
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.visit-media-item .video-play {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 1px 4px #000;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.visit-media-item .media-cap {
|
.visit-media-item .media-cap {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -52,8 +52,16 @@
|
|||||||
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
|
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
|
||||||
}
|
}
|
||||||
.media-row .vid-thumb {
|
.media-row .vid-thumb {
|
||||||
width: 80px; height: 60px; border-radius: 6px; background: #f0f0f0;
|
position: relative; width: 80px; height: 60px; border-radius: 6px;
|
||||||
display: flex; align-items: center; justify-content: center; font-size: 1.5rem;
|
overflow: hidden; background: #111;
|
||||||
|
}
|
||||||
|
.media-row .vid-thumb video {
|
||||||
|
width: 100%; height: 100%; display: block; object-fit: cover;
|
||||||
|
}
|
||||||
|
.media-row .video-play {
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
color: white; font-size: 1.35rem; line-height: 1;
|
||||||
|
text-shadow: 0 1px 4px #000; pointer-events: none;
|
||||||
}
|
}
|
||||||
.feedback-form { margin-top: 0.6rem; }
|
.feedback-form { margin-top: 0.6rem; }
|
||||||
.feedback-form textarea {
|
.feedback-form textarea {
|
||||||
@@ -152,7 +160,10 @@
|
|||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
|
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
|
||||||
<div class="vid-thumb">🎬</div>
|
<div class="vid-thumb">
|
||||||
|
<video src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||||
|
<span class="video-play">▶</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user