Files
AudiOhm/backend/app/api/v1/music.py
T
root 801e6a050b prod: UI Optimisée mise en production
- Documentation archivée et réorganisée
- Backend: Ajout tests, migrations, library service, rate limiting
- Frontend: Suppression Flutter, focus sur interface web HTML/JS
- Tailwind CSS ajouté pour le style
- Améliorations UX et corrections bugs

Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-20 09:56:39 +00:00

332 lines
9.8 KiB
Python

"""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