- Add pytest config with 90% coverage threshold - 15 test files covering all routers, services, schemas, models - 300 tests: unit tests, integration tests, edge cases, mocked external APIs - Update CI workflow to run pytest with coverage enforcement - Mock external services (Genius, MusicBrainz, RadioBrowser) - In-memory SQLite DB per test via conftest fixtures
167 lines
5.3 KiB
Python
167 lines
5.3 KiB
Python
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( # pragma: no cover
|
|
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)): # pragma: no cover
|
|
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"}
|