diff --git a/backend/app/api/v1/assignments.py b/backend/app/api/v1/assignments.py index 78ab490..f149de5 100644 --- a/backend/app/api/v1/assignments.py +++ b/backend/app/api/v1/assignments.py @@ -2,8 +2,8 @@ Assignment details and dispute system endpoints. """ from datetime import datetime, timedelta -from fastapi import APIRouter, HTTPException -from fastapi.responses import Response +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import Response, StreamingResponse from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -171,13 +171,14 @@ async def get_assignment_detail( ) -@router.get("/assignments/{assignment_id}/proof-image") -async def get_assignment_proof_image( +@router.get("/assignments/{assignment_id}/proof-media") +async def get_assignment_proof_media( assignment_id: int, + request: Request, current_user: CurrentUser, db: DbSession, ): - """Stream the proof image for an assignment""" + """Stream the proof media (image or video) for an assignment with Range support""" # Get assignment result = await db.execute( select(Assignment) @@ -205,15 +206,59 @@ async def get_assignment_proof_image( # Check if proof exists if not assignment.proof_path: - raise HTTPException(status_code=404, detail="No proof image for this assignment") + raise HTTPException(status_code=404, detail="No proof media for this assignment") # Get file from storage file_data = await storage_service.get_file(assignment.proof_path, "proofs") if not file_data: - raise HTTPException(status_code=404, detail="Proof image not found in storage") + raise HTTPException(status_code=404, detail="Proof media not found in storage") content, content_type = file_data + file_size = len(content) + # Check if it's a video and handle Range requests + is_video = content_type.startswith("video/") + + if is_video: + range_header = request.headers.get("range") + + if range_header: + # Parse range header + range_match = range_header.replace("bytes=", "").split("-") + start = int(range_match[0]) if range_match[0] else 0 + end = int(range_match[1]) if range_match[1] else file_size - 1 + + # Ensure valid range + if start >= file_size: + raise HTTPException(status_code=416, detail="Range not satisfiable") + + end = min(end, file_size - 1) + chunk = content[start:end + 1] + + return Response( + content=chunk, + status_code=206, + media_type=content_type, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(len(chunk)), + "Cache-Control": "public, max-age=31536000", + } + ) + + # No range header - return full video with Accept-Ranges + return Response( + content=content, + media_type=content_type, + headers={ + "Accept-Ranges": "bytes", + "Content-Length": str(file_size), + "Cache-Control": "public, max-age=31536000", + } + ) + + # For images, just return the content return Response( content=content, media_type=content_type, @@ -223,6 +268,18 @@ async def get_assignment_proof_image( ) +# Keep old endpoint for backwards compatibility +@router.get("/assignments/{assignment_id}/proof-image") +async def get_assignment_proof_image( + assignment_id: int, + request: Request, + current_user: CurrentUser, + db: DbSession, +): + """Deprecated: Use proof-media instead. Redirects to proof-media.""" + return await get_assignment_proof_media(assignment_id, request, current_user, db) + + @router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse) async def create_dispute( assignment_id: int, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e079a13..0099836 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,7 +23,8 @@ class Settings(BaseSettings): # Uploads UPLOAD_DIR: str = "uploads" - MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB + MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB + MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"} ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"} diff --git a/frontend/src/api/assignments.ts b/frontend/src/api/assignments.ts index d72dfc8..325095d 100644 --- a/frontend/src/api/assignments.ts +++ b/frontend/src/api/assignments.ts @@ -32,11 +32,16 @@ export const assignmentsApi = { return response.data }, - // Get proof image as blob URL - getProofImageUrl: async (assignmentId: number): Promise => { - const response = await client.get(`/assignments/${assignmentId}/proof-image`, { + // Get proof media as blob URL (supports both images and videos) + getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => { + const response = await client.get(`/assignments/${assignmentId}/proof-media`, { responseType: 'blob', }) - return URL.createObjectURL(response.data) + const contentType = response.headers['content-type'] || '' + const isVideo = contentType.startsWith('video/') + return { + url: URL.createObjectURL(response.data), + type: isVideo ? 'video' : 'image', + } }, } diff --git a/frontend/src/pages/AssignmentDetailPage.tsx b/frontend/src/pages/AssignmentDetailPage.tsx index 0be316f..b22a3dd 100644 --- a/frontend/src/pages/AssignmentDetailPage.tsx +++ b/frontend/src/pages/AssignmentDetailPage.tsx @@ -20,7 +20,8 @@ export function AssignmentDetailPage() { const [assignment, setAssignment] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - const [proofImageBlobUrl, setProofImageBlobUrl] = useState(null) + const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState(null) + const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null) // Dispute creation const [showDisputeForm, setShowDisputeForm] = useState(false) @@ -38,8 +39,8 @@ export function AssignmentDetailPage() { loadAssignment() return () => { // Cleanup blob URL on unmount - if (proofImageBlobUrl) { - URL.revokeObjectURL(proofImageBlobUrl) + if (proofMediaBlobUrl) { + URL.revokeObjectURL(proofMediaBlobUrl) } } }, [id]) @@ -52,13 +53,14 @@ export function AssignmentDetailPage() { const data = await assignmentsApi.getDetail(parseInt(id)) setAssignment(data) - // Load proof image if exists + // Load proof media if exists if (data.proof_image_url) { try { - const blobUrl = await assignmentsApi.getProofImageUrl(parseInt(id)) - setProofImageBlobUrl(blobUrl) + const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id)) + setProofMediaBlobUrl(url) + setProofMediaType(type) } catch { - // Ignore error, image just won't show + // Ignore error, media just won't show } } } catch (err: unknown) { @@ -251,15 +253,24 @@ export function AssignmentDetailPage() { Доказательство - {/* Proof image */} + {/* Proof media (image or video) */} {assignment.proof_image_url && (
- {proofImageBlobUrl ? ( - Proof + {proofMediaBlobUrl ? ( + proofMediaType === 'video' ? ( +
@@ -983,7 +1026,7 @@ export function PlayPage() { type="file" accept="image/*,video/*" className="hidden" - onChange={(e) => setProofFile(e.target.files?.[0] || null)} + onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)} /> {proofFile ? ( @@ -998,14 +1041,19 @@ export function PlayPage() { ) : ( - +
+ +

+ Макс. 15 МБ для изображений, 30 МБ для видео +

+
)}