feat: implement upload pipeline with compression and SSE

Backend:
- POST /api/v1/upload: multipart file upload with caption + hashtags
  - Validates file size against DB config limits (image/video separate)
  - Checks user ban status and event upload lock
  - Saves original to disk under {media_path}/originals/{slug}/
  - Tracks user total_upload_bytes for quota enforcement
  - Extracts hashtags from caption text and explicit CSV field
  - Upserts hashtags and links them to uploads
- PATCH /api/v1/upload/{id}: edit caption and hashtags (owner only)
- DELETE /api/v1/upload/{id}: soft-delete (owner only)
- GET /api/v1/stream: SSE endpoint with 30s keepalive
  - Broadcasts new-upload events to all connected clients
  - Uses tokio broadcast channel for fan-out

Services:
- CompressionWorker: Tokio semaphore-bounded (concurrency=2) background processor
  - Images: resize to 800px wide JPEG preview via image crate
  - PNG originals: lossless compression via oxipng
  - Videos: ffmpeg thumbnail extraction (1 frame at 1s, scaled to 800px)
  - Updates upload record with preview_path/thumbnail_path on completion

Models:
- Upload with full CRUD (create, find, update caption, soft delete, set paths)
- Hashtag with upsert, link/unlink, extract_hashtags() text parser
- UploadDto for API serialization with like/comment counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-03-31 21:48:59 +02:00
parent 8b9d916265
commit 3f052a4f91
12 changed files with 635 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ use sqlx::PgPool;
use tokio::sync::broadcast;
use crate::config::AppConfig;
use crate::services::compression::CompressionWorker;
#[derive(Clone, Debug)]
pub struct SseEvent {
@@ -14,15 +15,19 @@ pub struct AppState {
pub pool: PgPool,
pub config: AppConfig,
pub sse_tx: broadcast::Sender<SseEvent>,
pub compression: CompressionWorker,
}
impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256);
let compression =
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2);
Self {
pool,
config,
sse_tx,
compression,
}
}
}