"""Music API routes.""" import logging from typing import Optional from fastapi import APIRouter, HTTPException, Query, status, Request from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger(__name__) from app.api.dependencies import CurrentUser, CurrentUserOptional, DBSession from app.schemas.music import ( AlbumResponse, SearchRequest, SearchResponse, StreamUrlResponse, TrackResponse, TrackSearchResult, YouTubeSearchResult, ) from app.services.music_service import MusicService router = APIRouter(prefix="/music", tags=["music"]) @router.get("/search") async def search_music( db: DBSession, q: str = Query(..., min_length=1, max_length=100, description="Search query"), type: str = Query("track", pattern="^(track|artist|album|all)$"), limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), ): """ Search for music across database and YouTube. - **q**: Search query - **type**: Content type (track, artist, album, all) - **limit**: Maximum results (1-100) - **offset**: Pagination offset """ music_service = MusicService(db) results = await music_service.search( query=q, search_type=type, limit=limit, offset=offset, ) # Convert results without strict validation tracks = [] for t in results.get("tracks", []): # Use youtube_id as the id for YouTube-only results track_id = t.get("id") or t.get("youtube_id") track_data = { "title": t.get("title", "Unknown"), "youtube_id": t.get("youtube_id", ""), "duration": t.get("duration"), "image_url": t.get("thumbnail"), "artist_name": t.get("artist", "Unknown Artist"), "id": track_id, } tracks.append(track_data) return { "tracks": tracks, "artists": results.get("artists", []), "albums": results.get("albums", []), "total": results.get("total", len(tracks)), "query": results.get("query", q), } @router.get("/tracks/{track_id}", response_model=TrackResponse) async def get_track( track_id: str, db: DBSession, ): """ Get detailed information about a track. Requires authentication for full details. """ from uuid import UUID music_service = MusicService(db) try: track = await music_service.get_track(UUID(track_id)) if not track: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Track not found", ) return TrackResponse.model_validate(track) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid track ID", ) @router.get("/youtube/{youtube_id}/stream") async def stream_youtube_audio( youtube_id: str, db: DBSession, request: Request = None, ): """ Stream audio from a YouTube video. Downloads the audio as MP3 and streams it to the client. Supports HTTP Range requests for proper audio playback. """ music_service = MusicService(db) try: # Download audio as MP3 from pathlib import Path audio_path = await music_service.youtube.download_audio(youtube_id) if not audio_path or not audio_path.exists(): raise HTTPException( status_code=404, detail=f"Could not download audio for youtube_id: {youtube_id}" ) # Get file info file_size = audio_path.stat().st_size # Handle Range request range_header = request.headers.get("range") if request else None if range_header: # Parse Range header (format: "bytes=start-end") try: range_match = range_header.replace("bytes=", "").strip() range_parts = range_match.split("-") start = int(range_parts[0]) if range_parts[0] else 0 end = int(range_parts[1]) if len(range_parts) > 1 and range_parts[1] else file_size - 1 # Read the specific range with open(audio_path, "rb") as f: f.seek(start) chunk_size = end - start + 1 data = f.read(chunk_size) from fastapi.responses import Response return Response( content=data, status_code=206, # Partial Content media_type="audio/mpeg", headers={ "Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(chunk_size), "Content-Disposition": f"inline; filename={youtube_id}.mp3", } ) except Exception as e: logger.error(f"Error handling range request: {e}") # Fall through to full file response # Full file response from fastapi.responses import FileResponse return FileResponse( audio_path, media_type="audio/mpeg", filename=f"{youtube_id}.mp3", headers={ "Accept-Ranges": "bytes", "Content-Length": str(file_size), } ) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to stream audio: {str(e)}" ) @router.get("/tracks/{track_id}/stream") async def stream_track( track_id: str, db: DBSession, current_user: CurrentUserOptional = None, cache: bool = Query(False, description="Use cached version if available"), ): """ Get stream URL for a track or stream directly. Supports HTTP Range headers for proper streaming. """ from uuid import UUID music_service = MusicService(db) try: track = await music_service.get_track(UUID(track_id)) if not track: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Track not found", ) # Get stream URL stream_url = await music_service.get_stream_url(UUID(track_id)) if stream_url and stream_url.startswith("/api"): # Serve cached file from pathlib import Path cache_path = Path(f"./storage/audio/cache/{track.youtube_id}.mp3") if cache_path.exists(): return FileResponse( cache_path, media_type="audio/mpeg", filename=f"{track.title}.mp3", ) if not stream_url: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Stream URL not available", ) return StreamUrlResponse( url=stream_url, duration=track.duration, ) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid track ID", ) @router.post("/tracks/from-youtube", response_model=TrackResponse, status_code=status.HTTP_201_CREATED) async def create_track_from_youtube( youtube_id: str = Query(..., description="YouTube video ID"), title: str = Query(..., description="Track title"), artist: Optional[str] = Query(None, description="Artist name"), album: Optional[str] = Query(None, description="Album name"), db: DBSession = None, current_user: CurrentUser = None, ): """ Create a track from a YouTube video. Requires authentication. - **youtube_id**: YouTube video ID - **title**: Track title - **artist**: Optional artist name - **album**: Optional album name """ music_service = MusicService(db) try: track = await music_service.create_track_from_youtube( youtube_id=youtube_id, title=title, artist_name=artist, album_name=album, ) return TrackResponse.model_validate(track) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create track: {str(e)}", ) @router.get("/tracks/{track_id}/recommendations", response_model=list[YouTubeSearchResult]) async def get_track_recommendations( track_id: str, db: DBSession, limit: int = Query(10, ge=1, le=50), ): """ Get recommendations based on a track. Uses YouTube's related videos algorithm. """ from uuid import UUID music_service = MusicService(db) try: recommendations = await music_service.get_recommendations( UUID(track_id), limit=limit, ) return [YouTubeSearchResult(**r) for r in recommendations] except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid track ID", ) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get recommendations: {str(e)}", ) @router.get("/trending") async def get_trending( db: DBSession, limit: int = Query(20, ge=1, le=50), days: int = Query(7, ge=1, le=30, description="Number of days to look back"), ): """ Get trending tracks based on play count and recent listens. Returns the most played tracks from the database, sorted by popularity. Combines total play count with recent activity to determine trending tracks. """ music_service = MusicService(db) # Get trending tracks from database tracks = await music_service.get_trending(limit=limit, days=days) return tracks