Initial commit: AudiOhm - Alternative Spotify avec streaming YouTube
Backend: - FastAPI avec PostgreSQL et Redis - Authentification JWT complète - API REST pour musique, playlists, recherche - Streaming audio via yt-dlp - SQLAlchemy 2.0 async Frontend: - Flutter avec thème néon cyberpunk - State management Riverpod - Layout adaptatif desktop/mobile - Lecteur audio avec mini-player Infrastructure: - Docker Compose (PostgreSQL + Redis) - Scripts d'installation automatisés - Scripts de build pour exécutables Fichiers ajoutés: - BUILD_CLIENT_*.bat/sh: Scripts de compilation - BUILD_CLIENT_README.md: Documentation compilation - CHECK_FLUTTER.sh: Vérificateur d'environnement - requirements.txt mis à jour pour Python 3.13 - Modèles SQLAlchemy corrigés (metadata -> extra_metadata) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API v1 module."""
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Authentication API routes."""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.dependencies import AuthServiceDep, CurrentUser, DBSession
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
RefreshTokenRequest,
|
||||
Token,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
- **email**: Valid email address
|
||||
- **username**: 3-50 characters, unique
|
||||
- **password**: Min 8 characters
|
||||
- **display_name**: Optional display name
|
||||
"""
|
||||
try:
|
||||
user = await auth_service.register(
|
||||
email=user_data.email,
|
||||
username=user_data.username,
|
||||
password=user_data.password,
|
||||
display_name=user_data.display_name,
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
credentials: LoginRequest,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Login with email and password.
|
||||
|
||||
Returns access and refresh tokens.
|
||||
"""
|
||||
try:
|
||||
user = await auth_service.login(
|
||||
email=credentials.email,
|
||||
password=credentials.password,
|
||||
)
|
||||
access_token, refresh_token = auth_service.create_tokens(user.id)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=15 * 60, # 15 minutes
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e),
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
token_data: RefreshTokenRequest,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Refresh access token using refresh token.
|
||||
|
||||
Returns new access and refresh tokens.
|
||||
"""
|
||||
from app.core.security import decode_token
|
||||
|
||||
try:
|
||||
payload = decode_token(token_data.refresh_token)
|
||||
user_id = payload.get("sub")
|
||||
token_type = payload.get("type")
|
||||
|
||||
if user_id is None or token_type != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
# Verify user still exists
|
||||
user = await auth_service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
access_token, refresh_token = auth_service.create_tokens(user.id)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=15 * 60,
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user(
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
Get current authenticated user profile.
|
||||
|
||||
Requires authentication.
|
||||
"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_current_user(
|
||||
user_data: UserUpdate,
|
||||
current_user: CurrentUser,
|
||||
auth_service: AuthServiceDep,
|
||||
):
|
||||
"""
|
||||
Update current user profile.
|
||||
|
||||
Requires authentication.
|
||||
"""
|
||||
try:
|
||||
updated_user = await auth_service.update_user(
|
||||
user_id=current_user.id,
|
||||
display_name=user_data.display_name,
|
||||
avatar_url=user_data.avatar_url,
|
||||
date_of_birth=user_data.date_of_birth,
|
||||
country=user_data.country,
|
||||
)
|
||||
return UserResponse.model_validate(updated_user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
current_user: CurrentUser,
|
||||
):
|
||||
"""
|
||||
Logout current user.
|
||||
|
||||
In a stateless JWT setup, this is mainly for client-side cleanup.
|
||||
The token will expire automatically.
|
||||
|
||||
Requires authentication.
|
||||
"""
|
||||
# In production, you might want to:
|
||||
# - Add token to blacklist (Redis)
|
||||
# - Remove refresh token from database
|
||||
# - Log the logout event
|
||||
|
||||
return None
|
||||
@@ -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"]]
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Playlists API routes."""
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.api.dependencies import CurrentUser, DBSession
|
||||
from app.schemas.playlist import (
|
||||
AddTrackRequest,
|
||||
PlaylistCreate,
|
||||
PlaylistResponse,
|
||||
PlaylistWithTracks,
|
||||
PlaylistUpdate,
|
||||
PlaylistTrackResponse,
|
||||
ReorderTracksRequest,
|
||||
)
|
||||
from app.services.playlist_service import PlaylistService
|
||||
|
||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[PlaylistResponse])
|
||||
async def get_playlists(
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""
|
||||
Get all playlists for current user.
|
||||
|
||||
- **limit**: Maximum results (1-100)
|
||||
- **offset**: Pagination offset
|
||||
"""
|
||||
playlist_service = PlaylistService(db)
|
||||
playlists = await playlist_service.get_user_playlists(
|
||||
user_id=current_user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [PlaylistResponse.model_validate(p) for p in playlists]
|
||||
|
||||
|
||||
@router.post("", response_model=PlaylistResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_playlist(
|
||||
playlist_data: PlaylistCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Create a new playlist.
|
||||
|
||||
- **name**: Playlist name (required)
|
||||
- **description**: Optional description
|
||||
- **image_url**: Optional cover image URL
|
||||
- **is_public**: Whether playlist is public (default: false)
|
||||
- **is_collaborative**: Whether playlist is collaborative (default: false)
|
||||
"""
|
||||
playlist_service = PlaylistService(db)
|
||||
playlist = await playlist_service.create_playlist(
|
||||
user_id=current_user.id,
|
||||
name=playlist_data.name,
|
||||
description=playlist_data.description,
|
||||
image_url=playlist_data.image_url,
|
||||
is_public=playlist_data.is_public,
|
||||
is_collaborative=playlist_data.is_collaborative,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
|
||||
|
||||
@router.get("/{playlist_id}", response_model=PlaylistWithTracks)
|
||||
async def get_playlist(
|
||||
playlist_id: str,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
include_tracks: bool = Query(True, description="Include tracks in response"),
|
||||
):
|
||||
"""
|
||||
Get a playlist by ID.
|
||||
|
||||
- **include_tracks**: Whether to include tracks (default: true)
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.get_playlist(
|
||||
UUID(playlist_id),
|
||||
include_tracks=include_tracks,
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Playlist not found",
|
||||
)
|
||||
|
||||
# Check access permissions
|
||||
if not playlist.is_public and playlist.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to view this playlist",
|
||||
)
|
||||
|
||||
response = PlaylistWithTracks.model_validate(playlist)
|
||||
|
||||
if include_tracks and playlist.playlist_tracks:
|
||||
# Load tracks with details
|
||||
from sqlalchemy import select
|
||||
from app.models.track import Track
|
||||
|
||||
track_ids = [pt.track_id for pt in playlist.playlist_tracks]
|
||||
stmt = (
|
||||
select(Track)
|
||||
.where(Track.id.in_(track_ids))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
tracks = {t.id: t for t in result.scalars().all()}
|
||||
|
||||
# Build response with track details
|
||||
response.tracks = [
|
||||
{
|
||||
"id": str(pt.track_id),
|
||||
"position": pt.position,
|
||||
"added_at": pt.added_at.isoformat(),
|
||||
"added_by": str(pt.added_by) if pt.added_by else None,
|
||||
"track": {
|
||||
"id": str(tracks[pt.track_id].id),
|
||||
"title": tracks[pt.track_id].title,
|
||||
"duration": tracks[pt.track_id].duration,
|
||||
"artist": tracks[pt.track_id].artist.name if tracks[pt.track_id].artist else None,
|
||||
"image_url": tracks[pt.track_id].image_url,
|
||||
} if pt.track_id in tracks else None,
|
||||
}
|
||||
for pt in sorted(playlist.playlist_tracks, key=lambda x: x.position)
|
||||
]
|
||||
|
||||
return response
|
||||
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist ID",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{playlist_id}", response_model=PlaylistResponse)
|
||||
async def update_playlist(
|
||||
playlist_id: str,
|
||||
playlist_data: PlaylistUpdate,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Update a playlist.
|
||||
|
||||
Only the owner can update a playlist.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.update_playlist(
|
||||
playlist_id=UUID(playlist_id),
|
||||
user_id=current_user.id,
|
||||
name=playlist_data.name,
|
||||
description=playlist_data.description,
|
||||
image_url=playlist_data.image_url,
|
||||
is_public=playlist_data.is_public,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist ID",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_playlist(
|
||||
playlist_id: str,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Delete a playlist.
|
||||
|
||||
Only the owner can delete a playlist.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
await playlist_service.delete_playlist(
|
||||
playlist_id=UUID(playlist_id),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist ID",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/tracks", response_model=PlaylistResponse)
|
||||
async def add_tracks(
|
||||
playlist_id: str,
|
||||
track_data: AddTrackRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Add tracks to a playlist.
|
||||
|
||||
- **track_ids**: List of track UUIDs to add (1-100 tracks)
|
||||
- **position**: Optional starting position (default: end of playlist)
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.add_tracks(
|
||||
playlist_id=UUID(playlist_id),
|
||||
track_ids=track_data.track_ids,
|
||||
user_id=current_user.id,
|
||||
position=track_data.position,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist or track ID",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{playlist_id}/tracks/{track_id}", response_model=PlaylistResponse)
|
||||
async def remove_track(
|
||||
playlist_id: str,
|
||||
track_id: str,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Remove a track from a playlist.
|
||||
|
||||
Only the owner can remove tracks.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.remove_track(
|
||||
playlist_id=UUID(playlist_id),
|
||||
track_id=UUID(track_id),
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist or track ID",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{playlist_id}/tracks/reorder", response_model=PlaylistResponse)
|
||||
async def reorder_track(
|
||||
playlist_id: str,
|
||||
reorder_data: ReorderTracksRequest,
|
||||
current_user: CurrentUser,
|
||||
db: DBSession,
|
||||
):
|
||||
"""
|
||||
Reorder a track within a playlist.
|
||||
|
||||
- **track_id**: Track UUID to reorder
|
||||
- **new_position**: New position (0-indexed)
|
||||
|
||||
Only the owner can reorder tracks.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
playlist_service = PlaylistService(db)
|
||||
|
||||
try:
|
||||
playlist = await playlist_service.reorder_track(
|
||||
playlist_id=UUID(playlist_id),
|
||||
track_id=reorder_data.track_id,
|
||||
new_position=reorder_data.new_position,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return PlaylistResponse.model_validate(playlist)
|
||||
except ValueError as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid playlist or track ID",
|
||||
)
|
||||
Reference in New Issue
Block a user