🎉 Initial commit: AudiOhm - Alternative à Spotify avec streaming YouTube
Features: - Frontend Flutter avec thème néon cyberpunk - Backend FastAPI avec streaming YouTube - Base de données PostgreSQL + Redis - Authentification JWT complète - Recherche multi-source (DB + YouTube) - Playlists CRUD avec drag & drop - Queue management - Settings avec audio quality - Interface adaptative (Desktop + Mobile) Tech Stack: - Frontend: Flutter 3.2+, Riverpod - Backend: Python 3.11+, FastAPI - Database: PostgreSQL 15+ - Cache: Redis 7+ - Streaming: yt-dlp + FFmpeg 🚀 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""Music API routes."""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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", response_model=SearchResponse)
|
||||
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,
|
||||
)
|
||||
|
||||
return SearchResponse(
|
||||
tracks=[TrackSearchResult(**t) for t in results["tracks"]],
|
||||
artists=[AlbumResponse(**a) for a in results["artists"]],
|
||||
albums=[AlbumResponse(**a) for a in results["albums"]],
|
||||
total=results["total"],
|
||||
query=results["query"],
|
||||
)
|
||||
|
||||
|
||||
@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("/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", response_model=list[TrackSearchResult])
|
||||
async def get_trending(
|
||||
db: DBSession,
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
):
|
||||
"""
|
||||
Get trending tracks.
|
||||
|
||||
Currently returns placeholder data.
|
||||
In production, this would use actual trending data.
|
||||
"""
|
||||
music_service = MusicService(db)
|
||||
|
||||
# Search for popular music on YouTube
|
||||
results = await music_service.search("music 2024", search_type="track", limit=limit)
|
||||
|
||||
return [TrackSearchResult(**t) for t in results["tracks"]]
|
||||
Reference in New Issue
Block a user