801e6a050b
- 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>
332 lines
9.8 KiB
Python
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
|