from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from typing import Optional from ..db.database import get_db from ..models.song import Song from ..schemas.song import SongResponse, ScanResult from ..schemas.common import PaginatedResponse from ..services.audio import ( scan_directory, extract_metadata, delete_song as delete_song_service, get_stream_path, transcode_to_ogg, SUPPORTED_FORMATS ) import uuid import os import aiofiles router = APIRouter(prefix="/api/songs", tags=["songs"]) @router.get("", response_model=PaginatedResponse[SongResponse]) def list_songs( page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), ): offset = (page - 1) * per_page total = db.query(Song).count() songs = db.query(Song).offset(offset).limit(per_page).all() return PaginatedResponse( items=[SongResponse.model_validate(s) for s in songs], total=total, page=page, per_page=per_page, total_pages=max(1, (total + per_page - 1) // per_page), ) @router.get("/{song_id}", response_model=SongResponse) def get_song(song_id: str, db: Session = Depends(get_db)): song = db.query(Song).filter(Song.id == song_id).first() if not song: raise HTTPException(status_code=404, detail="Song not found") return SongResponse.model_validate(song) @router.get("/{song_id}/stream") async def stream_song( song_id: str, range: Optional[str] = None, db: Session = Depends(get_db), ): song = db.query(Song).filter(Song.id == song_id).first() if not song: raise HTTPException(status_code=404, detail="Song not found") file_path = get_stream_path(song) if not file_path: raise HTTPException(status_code=404, detail="Audio file not found") file_size = os.path.getsize(file_path) start = 0 end = file_size - 1 status_code = 200 chunk_size = 1024 * 1024 if range: try: range_str = range.replace("bytes=", "") start = int(range_str.split("-")[0]) end_part = range_str.split("-")[1] end = int(end_part) if end_part else file_size - 1 status_code = 206 except (ValueError, IndexError): pass headers = { "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(end - start + 1), } import mimetypes content_type = mimetypes.guess_type(file_path)[0] or "audio/octet-stream" async def stream(): async with aiofiles.open(file_path, mode="rb") as f: await f.seek(start) remaining = end - start + 1 while remaining > 0: chunk = await f.read(min(chunk_size, remaining)) if not chunk: break remaining -= len(chunk) yield chunk return StreamingResponse( stream(), status_code=status_code, headers=headers, media_type=content_type, ) @router.post("/upload") async def upload_song(file: UploadFile = File(...), db: Session = Depends(get_db)): upload_dir = os.getenv("UPLOAD_DIR", "./uploads") os.makedirs(upload_dir, exist_ok=True) ext = os.path.splitext(file.filename)[1].lower() if ext not in SUPPORTED_FORMATS: raise HTTPException(status_code=400, detail=f"Unsupported format: {ext}") file_id = str(uuid.uuid4()) saved_filename = f"{file_id}_{uuid.uuid4().hex[:8]}{ext}" file_path = os.path.join(upload_dir, saved_filename) # Stream file in chunks instead of loading entire file into memory file_size = 0 chunk_size = 8192 async with aiofiles.open(file_path, "wb") as f: while True: chunk = await file.read(chunk_size) if not chunk: break await f.write(chunk) file_size += len(chunk) metadata = extract_metadata(file_path) transcoded_path = transcode_to_ogg(file_path, upload_dir) song = Song( id=file_id, title=metadata.get("title") or file.filename.replace(ext, ""), artist=metadata.get("artist") or "Unknown", album=metadata.get("album"), duration_sec=metadata.get("duration", 0), genre=metadata.get("genre"), file_path=file_path, transcoded_path=transcoded_path, album_art_path=metadata.get("album_art_path"), file_format=ext.replace(".", ""), file_size_bytes=file_size, ) db.add(song) db.commit() db.refresh(song) return SongResponse.model_validate(song) @router.post("/scan", response_model=ScanResult) def scan_songs(directory: str = Query(None), db: Session = Depends(get_db)): if not directory: directory = os.getenv("MUSIC_DIR", "./music") return scan_directory(directory, db) @router.delete("/{song_id}") def delete_song(song_id: str, db: Session = Depends(get_db)): song = db.query(Song).filter(Song.id == song_id).first() if not song: raise HTTPException(status_code=404, detail="Song not found") delete_song_service(song) db.delete(song) db.commit() return {"message": "Song deleted"}