🎉 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:
feldenr
2026-01-18 17:08:59 +01:00
commit 9c504d2c3d
128 changed files with 22638 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""API module."""
+100
View File
@@ -0,0 +1,100 @@
"""API dependencies."""
from typing import Annotated, Optional
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
from app.services.auth_service import AuthService
# Database session dependency
DBSession = Annotated[AsyncSession, Depends(get_db)]
# Authentication
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: DBSession,
) -> User:
"""
Get current authenticated user from JWT token.
Args:
credentials: HTTP Authorization credentials
db: Database session
Returns:
Current user
Raises:
HTTPException: If token is invalid or user not found
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(credentials.credentials)
user_id: str = payload.get("sub")
token_type: str = payload.get("type")
if user_id is None or token_type != "access":
raise credentials_exception
except JWTError:
raise credentials_exception
auth_service = AuthService(db)
user = await auth_service.get_user_by_id(user_id)
if user is None:
raise credentials_exception
return user
async def get_current_user_optional(
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security)],
db: DBSession,
) -> Optional[User]:
"""
Get current user if authenticated, otherwise None.
Args:
credentials: Optional HTTP Authorization credentials
db: Database session
Returns:
Current user or None
"""
if credentials is None:
return None
try:
return await get_current_user(credentials, db) # type: ignore
except HTTPException:
return None
# Current user dependencies
CurrentUser = Annotated[User, Depends(get_current_user)]
CurrentUserOptional = Annotated[Optional[User], Depends(get_current_user_optional)]
def get_auth_service(db: DBSession) -> AuthService:
"""Get auth service instance."""
return AuthService(db)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
+1
View File
@@ -0,0 +1 @@
"""API v1 module."""
+178
View File
@@ -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
+227
View File
@@ -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"]]
+350
View File
@@ -0,0 +1,350 @@
"""Playlists API routes."""
from typing import List
from fastapi import APIRouter, HTTPException, Query
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",
)