rackham-meeting-analyzer/backend/app/api/uploads.py
2025-11-03 08:15:51 -06:00

210 lines
5.8 KiB
Python

"""
Upload API routes.
Handles chunked video file uploads.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from motor.motor_asyncio import AsyncIOMotorDatabase
from pydantic import BaseModel
from datetime import datetime
from bson import ObjectId
from app.core.deps import get_db, require_auth
from app.core.config import settings
from app.core.security import sanitize_filename, validate_video_file_extension, validate_file_size
from app.services.storage import storage_service
from app.models.job import JobStatus
router = APIRouter()
class UploadInitRequest(BaseModel):
filename: str
file_size: int
num_chunks: int
class UploadInitResponse(BaseModel):
job_id: str
chunk_size: int
@router.post("/init", response_model=UploadInitResponse)
async def init_upload(
request: UploadInitRequest,
user: dict = Depends(require_auth),
db: AsyncIOMotorDatabase = Depends(get_db)
):
"""
Initialize a chunked upload session.
Creates a new job and returns job ID and chunk size.
"""
# Sanitize filename first to prevent path traversal
sanitized_filename = sanitize_filename(request.filename)
# Validate file size
if not validate_file_size(request.file_size, settings.MAX_UPLOAD_SIZE_BYTES):
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds maximum allowed size of {settings.MAX_UPLOAD_SIZE_GB}GB"
)
# Validate file extension
if not validate_video_file_extension(sanitized_filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only video files are allowed (.mp4, .mov, .avi, .mkv)"
)
# Create new job
job_id = str(ObjectId())
job_doc = {
"_id": job_id,
"user_id": user["_id"],
"filename": sanitized_filename,
"file_size": request.file_size,
"num_chunks": request.num_chunks,
"chunks_received": 0,
"status": JobStatus.UPLOADING,
"progress": 0.0,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
await db.jobs.insert_one(job_doc)
return UploadInitResponse(
job_id=job_id,
chunk_size=settings.CHUNK_SIZE_BYTES
)
@router.post("/chunk", status_code=status.HTTP_204_NO_CONTENT)
async def upload_chunk(
request: Request,
user: dict = Depends(require_auth),
db: AsyncIOMotorDatabase = Depends(get_db)
):
"""
Upload a single chunk.
Expects query params: job_id, chunk_index
Body: raw chunk bytes
"""
# Get query parameters
job_id = request.query_params.get("job_id")
chunk_index_str = request.query_params.get("chunk_index")
if not job_id or not chunk_index_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing job_id or chunk_index"
)
try:
chunk_index = int(chunk_index_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid chunk_index"
)
# Verify job exists and belongs to user
job = await db.jobs.find_one({"_id": job_id, "user_id": user["_id"]})
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
if job["status"] != JobStatus.UPLOADING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Job is not in uploading state"
)
# Read chunk data
chunk_data = await request.body()
# Save chunk
await storage_service.save_chunk(job_id, chunk_index, chunk_data)
# Update job progress
chunks_received = job["chunks_received"] + 1
progress = (chunks_received / job["num_chunks"]) * 100
await db.jobs.update_one(
{"_id": job_id},
{
"$set": {
"chunks_received": chunks_received,
"progress": progress,
"updated_at": datetime.utcnow()
}
}
)
class UploadFinishRequest(BaseModel):
job_id: str
@router.post("/finish")
async def finish_upload(
request: UploadFinishRequest,
user: dict = Depends(require_auth),
db: AsyncIOMotorDatabase = Depends(get_db)
):
"""
Finish upload and assemble chunks into final video file.
"""
# Verify job
job = await db.jobs.find_one({"_id": request.job_id, "user_id": user["_id"]})
if not job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Job not found"
)
if job["status"] != JobStatus.UPLOADING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Job is not in uploading state"
)
# Verify all chunks received
if job["chunks_received"] != job["num_chunks"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Missing chunks: {job['chunks_received']}/{job['num_chunks']}"
)
# Assemble chunks
video_path = await storage_service.assemble_chunks(
request.job_id,
job["filename"],
job["num_chunks"]
)
if video_path is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to assemble video file"
)
# Update job status
await db.jobs.update_one(
{"_id": request.job_id},
{
"$set": {
"status": JobStatus.UPLOADED,
"video_path": str(video_path),
"progress": 100.0,
"updated_at": datetime.utcnow()
}
}
)
return {
"message": "Upload completed successfully",
"job_id": request.job_id
}