feat(backend): serve video with HTTP range requests

This commit is contained in:
2026-06-07 19:53:19 +03:00
parent 8cb1f4142b
commit b2a7787e45
3 changed files with 65 additions and 3 deletions

View File

@@ -120,6 +120,25 @@ impl FileRepository {
Ok(stream)
}
pub async fn stream_part(
&self,
file_id: i64,
user_id: i64,
from: u64,
to: u64,
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>, LoftError> {
info!(
"Fetching metadata of file \"{}\" from file_records",
file_id
);
let record = self.get_file(file_id, user_id).await?;
info!("Streaming chunk of file \"{}\"", file_id);
let reader = self.op.reader(&record.storage_key).await.unwrap();
let stream = reader.into_bytes_stream(from..to).await.unwrap();
Ok(stream)
}
pub async fn get_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> {
info!(
"Fetching metadata of file \"{}\" from file_records",

View File

@@ -2,6 +2,7 @@ use axum::{
Json, Router,
body::Body,
extract::{Multipart, Path, State},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse,
routing::get,
};
@@ -19,6 +20,7 @@ pub fn routes_file(file_repository: FileRepository) -> Router {
.route("/files", get(list_files).post(upload_file))
.route("/files/{id}", get(get_file).delete(delete_file))
.route("/files/{id}/download", get(download_file))
.route("/files/{id}/stream_part", get(stream_part))
.with_state(file_repository)
}
@@ -54,7 +56,6 @@ async fn upload_file(
Err(LoftError::UndefinedErrorType)
}
#[axum::debug_handler]
async fn get_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -66,7 +67,6 @@ async fn get_file(
Ok(Json(record))
}
#[axum::debug_handler]
async fn download_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,
@@ -78,6 +78,49 @@ async fn download_file(
Ok(Body::from_stream(stream))
}
async fn stream_part(
State(file_repository): State<FileRepository>,
ctx: Ctx,
headers: HeaderMap,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse, LoftError> {
info!("stream_part");
let file_record = file_repository
.get_file(file_id as i64, ctx.user_id())
.await?;
let file_size = file_record.size as u64;
let (start, end): (u64, u64) = parse_range(&headers, file_size);
let stream = file_repository
.stream_part(file_id as i64, ctx.user_id(), start, end + 1)
.await?;
let respones = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, file_record.file_type)
.header(header::CONTENT_LENGTH, end - start + 1)
.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{file_size}"),
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(stream))
.unwrap();
Ok(respones)
}
fn parse_range(headers: &HeaderMap, file_size: u64) -> (u64, u64) {
let range = headers.get(header::RANGE);
let str = range.unwrap().to_str().unwrap();
let strip = str.strip_prefix("bytes=").unwrap();
let tuple = strip.split_once("-").unwrap();
let start = tuple.0.parse::<u64>().unwrap();
let end = tuple.1.parse::<u64>().unwrap_or(file_size - 1);
(start, end)
}
async fn delete_file(
State(file_repository): State<FileRepository>,
ctx: Ctx,

View File

@@ -25,7 +25,7 @@
onclick={(e) => e.stopPropagation()}
>
{#if isVideo}
<VideoPreview src={`/api/files/${file.id}/download`} />
<VideoPreview src={`/api/files/${file.id}/stream_part`} />
<div
class="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between bg-linear-to-b from-black/70 to-transparent px-4 py-3"
>