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(db: AsyncSession = Depends(get_db)): result = await db.execute(select(Track).order_by(Track.created_at.desc())) return result.scalars().all() @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), ): # Check file type if not file.content_type or not file.content_type.startswith("audio/"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="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: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File size exceeds {settings.max_file_size_mb}MB limit", ) # Check storage limit if not await can_upload_file(file_size): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="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: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="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, ) db.add(track) await db.flush() return track @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) # 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}", } ) # 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}", } )