import uuid from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from mutagen.mp3 import MP3 from io import BytesIO from ..database import get_db from ..models.user import User from ..models.track import Track from ..schemas.track import TrackResponse, TrackWithUrl from ..services.auth import get_current_user from ..services.s3 import upload_file, delete_file, generate_presigned_url, can_upload_file, get_file_size, stream_file_chunks from ..config import get_settings settings = get_settings() router = APIRouter(prefix="/api/tracks", tags=["tracks"]) @router.get("", response_model=list[TrackResponse]) async def get_tracks( my: bool = False, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): query = select(Track) if my: query = query.where(Track.uploaded_by == current_user.id) query = query.order_by(Track.created_at.desc()) result = await db.execute(query) return result.scalars().all() async def _process_single_track( file: UploadFile, title: str, artist: str, current_user: User, ) -> tuple[Track, Exception | None]: """Process a single track upload. Returns (track, error).""" try: # Check file type if not file.content_type or not file.content_type.startswith("audio/"): return None, Exception("File must be an audio file") # Read file content content = await file.read() file_size = len(content) # Check file size max_size = settings.max_file_size_mb * 1024 * 1024 if file_size > max_size: return None, Exception(f"File size exceeds {settings.max_file_size_mb}MB limit") # Check storage limit if not await can_upload_file(file_size): return None, Exception("Storage limit exceeded") # Get duration and metadata from MP3 try: audio = MP3(BytesIO(content)) duration = int(audio.info.length * 1000) # Convert to milliseconds # Extract ID3 tags if title/artist not provided if not title or not artist: tags = audio.tags if tags: # TIT2 = Title, TPE1 = Artist if not title and tags.get("TIT2"): title = str(tags.get("TIT2")) if not artist and tags.get("TPE1"): artist = str(tags.get("TPE1")) # Fallback to filename if still no title if not title: title = file.filename.rsplit(".", 1)[0] if file.filename else "Unknown" if not artist: artist = "Unknown" except Exception as e: return None, Exception("Could not read audio file") # Upload to S3 s3_key = f"tracks/{uuid.uuid4()}.mp3" await upload_file(content, s3_key) # Create track record track = Track( title=title, artist=artist, duration=duration, s3_key=s3_key, file_size=file_size, uploaded_by=current_user.id, ) return track, None except Exception as e: return None, e @router.post("/upload", response_model=TrackResponse) async def upload_track( file: UploadFile = File(...), title: str = Form(None), artist: str = Form(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): track, error = await _process_single_track(file, title, artist, current_user) if error: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(error), ) db.add(track) await db.flush() return track @router.post("/upload-multiple", response_model=list[TrackResponse]) async def upload_multiple_tracks( files: list[UploadFile] = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Upload multiple tracks at once. Each file's metadata is auto-detected from ID3 tags.""" if not files: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No files provided", ) # Process all files results = [] for file in files: track, error = await _process_single_track(file, None, None, current_user) if track: db.add(track) results.append(track) # Commit all at once await db.flush() return results @router.get("/{track_id}", response_model=TrackWithUrl) async def get_track(track_id: uuid.UUID, db: AsyncSession = Depends(get_db)): result = await db.execute(select(Track).where(Track.id == track_id)) track = result.scalar_one_or_none() if not track: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found") url = generate_presigned_url(track.s3_key) return TrackWithUrl( id=track.id, title=track.title, artist=track.artist, duration=track.duration, file_size=track.file_size, uploaded_by=track.uploaded_by, created_at=track.created_at, url=url, ) @router.delete("/{track_id}") async def delete_track( track_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): result = await db.execute(select(Track).where(Track.id == track_id)) track = result.scalar_one_or_none() if not track: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found") if track.uploaded_by != current_user.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not track owner") # Delete from S3 await delete_file(track.s3_key) # Delete from DB await db.delete(track) return {"status": "deleted"} @router.get("/storage/info") async def get_storage_info(db: AsyncSession = Depends(get_db)): result = await db.execute(select(func.sum(Track.file_size))) total_size = result.scalar() or 0 max_size = settings.max_storage_gb * 1024 * 1024 * 1024 return { "used_bytes": total_size, "max_bytes": max_size, "used_gb": round(total_size / (1024 * 1024 * 1024), 2), "max_gb": settings.max_storage_gb, } @router.get("/{track_id}/stream") async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): """Stream audio file through backend with Range support (bypasses S3 SSL issues)""" result = await db.execute(select(Track).where(Track.id == track_id)) track = result.scalar_one_or_none() if not track: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found") # Get file size from S3 (without downloading) file_size = get_file_size(track.s3_key) if file_size is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track file not found in storage") # Encode filename for non-ASCII characters encoded_filename = quote(f"{track.title}.mp3") # Parse Range header range_header = request.headers.get("range") if range_header: # Parse "bytes=start-end" 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) content_length = end - start + 1 return StreamingResponse( stream_file_chunks(track.s3_key, start, end), status_code=206, media_type="audio/mpeg", headers={ "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length), "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}", "X-Accel-Buffering": "no", "Cache-Control": "no-cache", } ) # No range - stream full file return StreamingResponse( stream_file_chunks(track.s3_key), media_type="audio/mpeg", headers={ "Accept-Ranges": "bytes", "Content-Length": str(file_size), "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}", "X-Accel-Buffering": "no", "Cache-Control": "no-cache", } )