feat: generate and display thumbnails asynchronously

This commit is contained in:
2026-06-28 12:18:26 +03:00
parent 23933f47a0
commit affe9bcd14
16 changed files with 886 additions and 33 deletions

View File

@@ -7,12 +7,15 @@ use axum::{
routing::get,
};
use sqlx::types::uuid;
use tokio::sync::mpsc::Sender;
use tracing::warn;
use utoipa::OpenApi;
use crate::{
ctx::Ctx,
error::{LoftError, Result},
model::{FileRecord, FileRepository},
tasks::ThumbnailTask,
};
#[derive(OpenApi)]
@@ -23,19 +26,36 @@ use crate::{
get_file,
delete_file,
download_file,
thumbnail,
stream_part
),
components(schemas(FileRecord))
)]
pub struct FileApi;
pub fn routes_file(file_repository: FileRepository) -> Router {
#[derive(Clone)]
pub struct FileState {
pub file_repository: FileRepository,
pub tx: Sender<ThumbnailTask>,
}
impl FileState {
pub fn new(file_repository: FileRepository, tx: Sender<ThumbnailTask>) -> Self {
Self {
file_repository,
tx,
}
}
}
pub fn routes_file(file_service: FileState) -> Router {
Router::new()
.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}/thumbnail", get(thumbnail))
.route("/files/{id}/stream_part", get(stream_part))
.with_state(file_repository)
.with_state(file_service)
}
/// Upload a file
@@ -53,7 +73,7 @@ pub fn routes_file(file_repository: FileRepository) -> Router {
)
)]
async fn upload_file(
State(file_repository): State<FileRepository>,
State(service): State<FileState>,
ctx: Ctx,
mut multipart: Multipart,
) -> Result<(StatusCode, Json<FileRecord>)> {
@@ -63,18 +83,27 @@ async fn upload_file(
if field.name() == Some("file") {
let name = field.file_name().map(str::to_string).unwrap_or_default();
let key = format!("{name}-{}", uuid::Uuid::new_v4());
let size = file_repository.upload_file(field, &key).await?;
let size = service.file_repository.upload_file(field, &key).await?;
uploaded = Some((name, key, size));
}
}
let (name, key, size) = uploaded.ok_or(LoftError::NoFileProvided)?;
let file_record = file_repository
let record = service
.file_repository
.create_file_record(ctx.user_id(), &name, size, &key)
.await?;
Ok((StatusCode::CREATED, Json(file_record)))
if let Err(e) = service
.tx
.send(ThumbnailTask::new(record.id, ctx.user_id()))
.await
{
warn!("failed to send thumbnail task for file {}: {e}", record.id)
}
Ok((StatusCode::CREATED, Json(record)))
}
/// Get a file's metadata
@@ -92,11 +121,12 @@ async fn upload_file(
)
)]
async fn get_file(
State(file_repository): State<FileRepository>,
State(service): State<FileState>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>> {
let record = file_repository
let record = service
.file_repository
.get_file(file_id as i64, ctx.user_id())
.await?;
Ok(Json(record))
@@ -117,16 +147,43 @@ async fn get_file(
)
)]
async fn download_file(
State(file_repository): State<FileRepository>,
State(service): State<FileState>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse> {
let stream = file_repository
let stream = service
.file_repository
.download_file(file_id as i64, ctx.user_id())
.await?;
Ok(Body::from_stream(stream))
}
/// Download a file thumbnail
#[utoipa::path(
get,
path = "/api/files/{id}/thumbnail",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "File bytes", content_type = "application/octet-stream"),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn thumbnail(
State(service): State<FileState>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse> {
let stream = service
.file_repository
.thumbnail(file_id as i64, ctx.user_id())
.await?;
Ok(Body::from_stream(stream))
}
/// Stream a byte range of a file (HTTP 206)
#[utoipa::path(
get,
@@ -146,24 +203,26 @@ async fn download_file(
)
)]
async fn stream_part(
State(file_repository): State<FileRepository>,
State(service): State<FileState>,
ctx: Ctx,
headers: HeaderMap,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse> {
let file_record = file_repository
let record = service
.file_repository
.get_file(file_id as i64, ctx.user_id())
.await?;
let file_size = file_record.size as u64;
let file_size = record.size as u64;
let (start, end): (u64, u64) = parse_range(&headers, file_size)?;
let stream = file_repository
let stream = service
.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_TYPE, record.file_type)
.header(header::CONTENT_LENGTH, end - start + 1)
.header(
header::CONTENT_RANGE,
@@ -207,11 +266,12 @@ fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> {
)
)]
async fn delete_file(
State(file_repository): State<FileRepository>,
State(service): State<FileState>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>> {
let file = file_repository
let file = service
.file_repository
.delete_file(file_id as i64, ctx.user_id())
.await?;
Ok(Json(file))
@@ -229,11 +289,8 @@ async fn delete_file(
(status = 500, description = "Internal server error")
)
)]
async fn list_files(
State(file_repository): State<FileRepository>,
ctx: Ctx,
) -> Result<Json<Vec<FileRecord>>> {
let files = file_repository.list_files(ctx.user_id()).await?;
async fn list_files(State(service): State<FileState>, ctx: Ctx) -> Result<Json<Vec<FileRecord>>> {
let files = service.file_repository.list_files(ctx.user_id()).await?;
Ok(Json(files))
}
@@ -251,13 +308,14 @@ mod tests {
use rand::RngExt;
use serde_json::json;
use sqlx::PgPool;
use tokio::sync::mpsc;
use tower_cookies::CookieManagerLayer;
use crate::{
model::{FileRepository, UserRepository},
web::{
mw_auth::{mw_ctx_resolver, mw_require_auth},
routes_file::routes_file,
routes_file::{FileState, routes_file},
},
};
@@ -282,8 +340,12 @@ mod tests {
async fn test_server() -> TestServer {
let user_repository = user_repository().await;
let file_repository = file_repository().await;
let (tx, _) = mpsc::channel(100);
let file_service = FileState::new(file_repository.clone(), tx);
let routes_file =
routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth));
routes_file(file_service).route_layer(middleware::from_fn(mw_require_auth));
let app = Router::new()
.nest("/api", routes_file)
.layer(middleware::from_fn_with_state(