210 lines
5.8 KiB
Python
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
|
|
}
|