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,114 @@
|
||||
# =============================================================================
|
||||
# Spotify Le 2 - Environment Variables
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in your values.
|
||||
# DO NOT commit .env to version control.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application
|
||||
# -----------------------------------------------------------------------------
|
||||
APP_NAME="Spotify Le 2"
|
||||
APP_VERSION="0.1.0"
|
||||
DEBUG=true
|
||||
API_V1_PREFIX="/api/v1"
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
|
||||
# CORS - Comma-separated list of allowed origins
|
||||
BACKEND_CORS_ORIGINS=http://localhost:3000,http://localhost:8000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (PostgreSQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=spotify
|
||||
POSTGRES_PASSWORD=change_this_password_in_production
|
||||
POSTGRES_DB=spotify_le_2
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis
|
||||
# -----------------------------------------------------------------------------
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
# Alternative: Full Redis URL (overrides above if set)
|
||||
# REDIS_URL=redis://:password@localhost:6379/0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# JWT Security
|
||||
# -----------------------------------------------------------------------------
|
||||
# IMPORTANT: Change this to a strong random string in production!
|
||||
SECRET_KEY=change-this-secret-key-in-production-use-openssl-rand-hex-32
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# Password hashing
|
||||
PASSWORD_HASH_ALGORITHM=bcrypt
|
||||
PASSWORD_HASH_ROUNDS=12
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Storage
|
||||
# -----------------------------------------------------------------------------
|
||||
STORAGE_PATH=./storage
|
||||
AUDIO_CACHE_PATH=./storage/audio/cache
|
||||
AUDIO_PERMANENT_PATH=./storage/audio/permanent
|
||||
THUMBNAILS_PATH=./storage/thumbnails
|
||||
MAX_CACHE_SIZE_GB=50
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# YouTube
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get your API key from: https://console.cloud.google.com/
|
||||
YOUTUBE_API_KEY=
|
||||
YTDLP_PATH=yt-dlp
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Spotify (for playlist import)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get your credentials from: https://developer.spotify.com/dashboard
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_REDIRECT_URI=http://localhost:8000/api/v1/import/spotify/callback
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Last.fm (for metadata and recommendations)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Get your API key from: https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_SECRET=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Rate Limiting
|
||||
# -----------------------------------------------------------------------------
|
||||
RATE_LIMIT_PER_MINUTE=100
|
||||
RATE_LIMIT_BURST=200
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# File Upload
|
||||
# -----------------------------------------------------------------------------
|
||||
MAX_FILE_SIZE_MB=100
|
||||
ALLOWED_AUDIO_TYPES=audio/mpeg,audio/ogg,audio/wav,audio/flac
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Audio Processing
|
||||
# -----------------------------------------------------------------------------
|
||||
FFMPEG_PATH=ffmpeg
|
||||
AUDIO_QUALITY=high
|
||||
BITRATE=320
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE_PATH=./logs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pagination
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_PAGE_SIZE=20
|
||||
MAX_PAGE_SIZE=100
|
||||
@@ -0,0 +1,78 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Storage
|
||||
storage/
|
||||
*.mp3
|
||||
*.mp4
|
||||
*.wav
|
||||
*.flac
|
||||
*.ogg
|
||||
*.m4a
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.ruff_cache/
|
||||
|
||||
# Distribution
|
||||
*.whl
|
||||
|
||||
# Alembic
|
||||
alembic/versions/*.pyc
|
||||
@@ -0,0 +1 @@
|
||||
"""Application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API module."""
|
||||
@@ -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)]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Core module."""
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Application configuration using Pydantic Settings."""
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "AudiOhm"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Server
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS: list[str] = Field(
|
||||
default=["http://localhost:3000", "http://localhost:8000"],
|
||||
description="List of allowed CORS origins",
|
||||
)
|
||||
|
||||
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v: str | list[str]) -> list[str]:
|
||||
"""Parse CORS origins from string or list."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",")]
|
||||
return v
|
||||
|
||||
# Database
|
||||
POSTGRES_HOST: str = "localhost"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str = "spotify"
|
||||
POSTGRES_PASSWORD: str = "spotify_password"
|
||||
POSTGRES_DB: str = "spotify_le_2"
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
"""Build PostgreSQL async connection URL."""
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
|
||||
)
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: str | None = None
|
||||
REDIS_DB: int = 0
|
||||
REDIS_URL: str | None = None
|
||||
|
||||
@property
|
||||
def FULL_REDIS_URL(self) -> str:
|
||||
"""Build Redis connection URL."""
|
||||
if self.REDIS_URL:
|
||||
return self.REDIS_URL
|
||||
auth = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
|
||||
return f"redis://{auth}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = Field(
|
||||
default="change-this-secret-key-in-production",
|
||||
description="Secret key for JWT token signing",
|
||||
)
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Password hashing
|
||||
PASSWORD_HASH_ALGORITHM: Literal["bcrypt"] = "bcrypt"
|
||||
PASSWORD_HASH_ROUNDS: int = 12
|
||||
|
||||
# Storage
|
||||
STORAGE_PATH: str = "./storage"
|
||||
AUDIO_CACHE_PATH: str = "./storage/audio/cache"
|
||||
AUDIO_PERMANENT_PATH: str = "./storage/audio/permanent"
|
||||
THUMBNAILS_PATH: str = "./storage/thumbnails"
|
||||
MAX_CACHE_SIZE_GB: int = 50
|
||||
|
||||
# YouTube
|
||||
YOUTUBE_API_KEY: str | None = None
|
||||
YTDLP_PATH: str = "yt-dlp"
|
||||
|
||||
# Spotify (for import)
|
||||
SPOTIFY_CLIENT_ID: str | None = None
|
||||
SPOTIFY_CLIENT_SECRET: str | None = None
|
||||
SPOTIFY_REDIRECT_URI: str = "http://localhost:8000/api/v1/import/spotify/callback"
|
||||
|
||||
# Last.fm (for metadata)
|
||||
LASTFM_API_KEY: str | None = None
|
||||
LASTFM_SECRET: str | None = None
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_PER_MINUTE: int = 100
|
||||
RATE_LIMIT_BURST: int = 200
|
||||
|
||||
# Upload
|
||||
MAX_FILE_SIZE_MB: int = 100
|
||||
ALLOWED_AUDIO_TYPES: list[str] = [
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"audio/flac",
|
||||
]
|
||||
|
||||
# Audio processing
|
||||
FFMPEG_PATH: str = "ffmpeg"
|
||||
AUDIO_QUALITY: Literal["low", "medium", "high"] = "high"
|
||||
BITRATE: int = 320
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
||||
LOG_FILE_PATH: str = "./logs"
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Initialize settings and create storage directories."""
|
||||
super().__init__(**kwargs)
|
||||
self._ensure_storage_directories()
|
||||
|
||||
def _ensure_storage_directories(self) -> None:
|
||||
"""Create storage directories if they don't exist."""
|
||||
from pathlib import Path
|
||||
|
||||
for path in [
|
||||
self.STORAGE_PATH,
|
||||
self.AUDIO_CACHE_PATH,
|
||||
self.AUDIO_PERMANENT_PATH,
|
||||
self.THUMBNAILS_PATH,
|
||||
self.LOG_FILE_PATH,
|
||||
]:
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Database configuration and session management."""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
async_session_maker = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Dependency function to get database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session.
|
||||
|
||||
Example:
|
||||
@app.get("/users/")
|
||||
async def get_users(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User))
|
||||
return result.scalars().all()
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_context() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Context manager for database session.
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session.
|
||||
|
||||
Example:
|
||||
async with get_db_context() as db:
|
||||
await db.execute(insert(User).values(**user_data))
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize database tables."""
|
||||
async with engine.begin() as conn:
|
||||
# Import all models here to ensure they're registered with Base
|
||||
from app.models import ( # noqa: F401
|
||||
album,
|
||||
artist,
|
||||
playlist,
|
||||
playlist_track,
|
||||
track,
|
||||
user,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close database connections."""
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Security utilities for authentication and password hashing."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(
|
||||
schemes=[settings.PASSWORD_HASH_ALGORITHM],
|
||||
deprecated="auto",
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
Verify a plain password against a hashed password.
|
||||
|
||||
Args:
|
||||
plain_password: The plain text password to verify.
|
||||
hashed_password: The hashed password to compare against.
|
||||
|
||||
Returns:
|
||||
True if the password matches, False otherwise.
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
Hash a password using the configured algorithm.
|
||||
|
||||
Args:
|
||||
password: The plain text password to hash.
|
||||
|
||||
Returns:
|
||||
The hashed password.
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
Args:
|
||||
subject: The subject of the token (usually user ID).
|
||||
expires_delta: Optional expiration time delta.
|
||||
Defaults to settings.ACCESS_TOKEN_EXPIRE_MINUTES.
|
||||
|
||||
Returns:
|
||||
The encoded JWT access token.
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.SECRET_KEY,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(
|
||||
subject: str | Any,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a JWT refresh token.
|
||||
|
||||
Args:
|
||||
subject: The subject of the token (usually user ID).
|
||||
expires_delta: Optional expiration time delta.
|
||||
Defaults to settings.REFRESH_TOKEN_EXPIRE_DAYS.
|
||||
|
||||
Returns:
|
||||
The encoded JWT refresh token.
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.SECRET_KEY,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Args:
|
||||
token: The JWT token to decode.
|
||||
|
||||
Returns:
|
||||
The decoded token payload.
|
||||
|
||||
Raises:
|
||||
jwt.JWTError: If the token is invalid or expired.
|
||||
"""
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Main FastAPI application entry point."""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import close_db, init_db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""
|
||||
Lifespan context manager for FastAPI application.
|
||||
|
||||
Handles startup and shutdown events.
|
||||
"""
|
||||
# Startup
|
||||
print("Starting up...")
|
||||
if settings.DEBUG:
|
||||
print("Debug mode is ON")
|
||||
print(f"Database URL: {settings.DATABASE_URL}")
|
||||
print(f"Redis URL: {settings.FULL_REDIS_URL}")
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
print("Database initialized")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print("Shutting down...")
|
||||
await close_db()
|
||||
print("Database connections closed")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="Alternative to Spotify with YouTube streaming",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.BACKEND_CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> dict[str, str]:
|
||||
"""Root endpoint with API information."""
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
"status": "running",
|
||||
"docs": "/api/docs",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/api/v1")
|
||||
async def api_v1_info() -> dict[str, str]:
|
||||
"""API v1 information endpoint."""
|
||||
return {
|
||||
"version": "v1",
|
||||
"prefix": settings.API_V1_PREFIX,
|
||||
"docs": "/api/docs",
|
||||
}
|
||||
|
||||
|
||||
# Exception handlers
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc) -> JSONResponse:
|
||||
"""Global exception handler for unhandled exceptions."""
|
||||
if settings.DEBUG:
|
||||
# In debug mode, return full error details
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"detail": str(exc),
|
||||
"type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
# In production, return generic error message
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
# API routes
|
||||
from app.api.v1 import auth, music, playlists
|
||||
|
||||
app.include_router(auth.router, prefix=settings.API_V1_PREFIX, tags=["authentication"])
|
||||
app.include_router(music.router, prefix=settings.API_V1_PREFIX, tags=["music"])
|
||||
app.include_router(playlists.router, prefix=settings.API_V1_PREFIX, tags=["playlists"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.HOST,
|
||||
port=settings.PORT,
|
||||
reload=settings.DEBUG,
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""SQLAlchemy models."""
|
||||
from app.models.album import Album
|
||||
from app.models.artist import Artist
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
from app.models.track import Track
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"Album",
|
||||
"Artist",
|
||||
"Playlist",
|
||||
"PlaylistTrack",
|
||||
"Track",
|
||||
"User",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Album model."""
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Integer, DATE, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.artist import Artist
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class Album(Base):
|
||||
"""Album model representing music albums."""
|
||||
|
||||
__tablename__ = "albums"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
release_date: Mapped[date | None] = mapped_column(
|
||||
DATE,
|
||||
)
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
|
||||
# Album details
|
||||
total_tracks: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
genre: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
|
||||
# Foreign key to artist
|
||||
artist_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("artists.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# External IDs
|
||||
spotify_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
youtube_playlist_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Additional metadata stored as JSON
|
||||
extra_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata",
|
||||
JSONB,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
artist: Mapped["Artist"] = relationship(
|
||||
"Artist",
|
||||
back_populates="albums",
|
||||
lazy="selectin",
|
||||
)
|
||||
tracks: Mapped[list["Track"]] = relationship(
|
||||
"Track",
|
||||
back_populates="album",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Album {self.title}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert album model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title,
|
||||
"release_date": self.release_date.isoformat() if self.release_date else None,
|
||||
"image_url": self.image_url,
|
||||
"total_tracks": self.total_tracks,
|
||||
"genre": self.genre,
|
||||
"artist_id": str(self.artist_id) if self.artist_id else None,
|
||||
"spotify_id": self.spotify_id,
|
||||
"youtube_playlist_id": self.youtube_playlist_id,
|
||||
"metadata": self.extra_metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Artist model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, ARRAY, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.album import Album
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class Artist(Base):
|
||||
"""Artist model representing music artists."""
|
||||
|
||||
__tablename__ = "artists"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
bio: Mapped[str | None] = mapped_column(
|
||||
String(2000),
|
||||
)
|
||||
|
||||
# Genres as array
|
||||
genres: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(100)),
|
||||
default=list,
|
||||
)
|
||||
|
||||
# Popularity score (0-100)
|
||||
popularity: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
|
||||
# External IDs
|
||||
spotify_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
youtube_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Additional metadata stored as JSON
|
||||
extra_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata",
|
||||
JSONB,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
albums: Mapped[list["Album"]] = relationship(
|
||||
"Album",
|
||||
back_populates="artist",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
tracks: Mapped[list["Track"]] = relationship(
|
||||
"Track",
|
||||
back_populates="artist",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Artist {self.name}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert artist model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"name": self.name,
|
||||
"image_url": self.image_url,
|
||||
"bio": self.bio,
|
||||
"genres": self.genres,
|
||||
"popularity": self.popularity,
|
||||
"spotify_id": self.spotify_id,
|
||||
"youtube_id": self.youtube_id,
|
||||
"metadata": self.extra_metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Playlist model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Integer, Text, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
|
||||
|
||||
class Playlist(Base):
|
||||
"""Playlist model representing user playlists."""
|
||||
|
||||
__tablename__ = "playlists"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Foreign key to user
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text,
|
||||
)
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
|
||||
# Playlist flags
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
index=True,
|
||||
)
|
||||
is_collaborative: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
)
|
||||
is_smart: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
comment="True for rules-based smart playlists",
|
||||
)
|
||||
|
||||
# Smart playlist rules stored as JSON
|
||||
smart_rules: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
default=dict,
|
||||
comment="Rules for smart playlists",
|
||||
)
|
||||
|
||||
# Playlist stats
|
||||
track_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
total_duration: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
comment="Total duration in seconds",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="playlists",
|
||||
lazy="selectin",
|
||||
)
|
||||
playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship(
|
||||
"PlaylistTrack",
|
||||
back_populates="playlist",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
order_by="PlaylistTrack.position",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Playlist {self.name}>"
|
||||
|
||||
def to_dict(self, include_tracks: bool = False) -> dict:
|
||||
"""Convert playlist model to dictionary."""
|
||||
data = {
|
||||
"id": str(self.id),
|
||||
"user_id": str(self.user_id),
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"image_url": self.image_url,
|
||||
"is_public": self.is_public,
|
||||
"is_collaborative": self.is_collaborative,
|
||||
"is_smart": self.is_smart,
|
||||
"smart_rules": self.smart_rules,
|
||||
"track_count": self.track_count,
|
||||
"total_duration": self.total_duration,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
if include_tracks:
|
||||
data["tracks"] = [pt.track.to_dict() for pt in self.playlist_tracks]
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Playlist-Track association model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Integer, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.track import Track
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class PlaylistTrack(Base):
|
||||
"""Association model for Playlist-Track many-to-many relationship."""
|
||||
|
||||
__tablename__ = "playlist_tracks"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("playlist_id", "position", name="uq_playlist_position"),
|
||||
)
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
playlist_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("playlists.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
track_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tracks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Position in playlist (starts at 0)
|
||||
position: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# User who added this track to playlist
|
||||
added_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Timestamp when track was added
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
playlist: Mapped["Playlist"] = relationship(
|
||||
"Playlist",
|
||||
back_populates="playlist_tracks",
|
||||
lazy="selectin",
|
||||
)
|
||||
track: Mapped["Track"] = relationship(
|
||||
"Track",
|
||||
lazy="selectin",
|
||||
)
|
||||
added_by_user: Mapped["User"] = relationship(
|
||||
"User",
|
||||
back_populates="added_playlist_tracks",
|
||||
lazy="selectin",
|
||||
foreign_keys=[added_by],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PlaylistTrack playlist={self.playlist_id} track={self.track_id} pos={self.position}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert playlist-track model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"playlist_id": str(self.playlist_id),
|
||||
"track_id": str(self.track_id),
|
||||
"position": self.position,
|
||||
"added_by": str(self.added_by) if self.added_by else None,
|
||||
"added_at": self.added_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Track model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.artist import Artist
|
||||
from app.models.album import Album
|
||||
|
||||
|
||||
class Track(Base):
|
||||
"""Track model representing music tracks."""
|
||||
|
||||
__tablename__ = "tracks"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Basic info
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
duration: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
comment="Duration in seconds",
|
||||
)
|
||||
|
||||
# Track position
|
||||
track_number: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
)
|
||||
disc_number: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
)
|
||||
|
||||
# Cover art
|
||||
image_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
|
||||
# Audio URLs
|
||||
audio_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
comment="Cached audio URL",
|
||||
)
|
||||
audio_quality: Mapped[str | None] = mapped_column(
|
||||
String(20),
|
||||
comment="low, medium, high",
|
||||
)
|
||||
|
||||
# Genre and mood
|
||||
genre: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
mood: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
|
||||
# Play count
|
||||
play_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
)
|
||||
|
||||
# Foreign keys
|
||||
artist_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("artists.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
album_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("albums.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# External IDs (unique indices)
|
||||
spotify_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
youtube_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
soundcloud_id: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Additional metadata stored as JSON
|
||||
extra_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata",
|
||||
JSONB,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
artist: Mapped["Artist"] = relationship(
|
||||
"Artist",
|
||||
back_populates="tracks",
|
||||
lazy="selectin",
|
||||
)
|
||||
album: Mapped["Album"] = relationship(
|
||||
"Album",
|
||||
back_populates="tracks",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Track {self.title}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert track model to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title,
|
||||
"duration": self.duration,
|
||||
"track_number": self.track_number,
|
||||
"disc_number": self.disc_number,
|
||||
"image_url": self.image_url,
|
||||
"audio_url": self.audio_url,
|
||||
"audio_quality": self.audio_quality,
|
||||
"genre": self.genre,
|
||||
"mood": self.mood,
|
||||
"play_count": self.play_count,
|
||||
"artist_id": str(self.artist_id) if self.artist_id else None,
|
||||
"album_id": str(self.album_id) if self.album_id else None,
|
||||
"spotify_id": self.spotify_id,
|
||||
"youtube_id": self.youtube_id,
|
||||
"soundcloud_id": self.soundcloud_id,
|
||||
"metadata": self.extra_metadata,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""User model."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DATE, String, Boolean, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication and user management."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
# Primary key
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Authentication fields
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Profile fields
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(
|
||||
String(100),
|
||||
)
|
||||
avatar_url: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
)
|
||||
date_of_birth: Mapped[datetime | None] = mapped_column(
|
||||
DATE,
|
||||
)
|
||||
country: Mapped[str | None] = mapped_column(
|
||||
String(2),
|
||||
)
|
||||
|
||||
# Preferences and settings stored as JSON
|
||||
preferences: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
# Premium status (for future use)
|
||||
is_premium: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
last_login: Mapped[datetime | None] = mapped_column(
|
||||
default=None,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
playlists: Mapped[list["Playlist"]] = relationship(
|
||||
"Playlist",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
added_playlist_tracks: Mapped[list["PlaylistTrack"]] = relationship(
|
||||
"PlaylistTrack",
|
||||
back_populates="added_by_user",
|
||||
foreign_keys="PlaylistTrack.added_by",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username} ({self.email})>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert user model to dictionary (excluding sensitive data)."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"email": self.email,
|
||||
"username": self.username,
|
||||
"display_name": self.display_name,
|
||||
"avatar_url": self.avatar_url,
|
||||
"date_of_birth": self.date_of_birth.isoformat() if self.date_of_birth else None,
|
||||
"country": self.country,
|
||||
"preferences": self.preferences,
|
||||
"is_premium": self.is_premium,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Schemas module."""
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Authentication schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user schema."""
|
||||
|
||||
email: EmailStr
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Schema for creating a new user."""
|
||||
|
||||
password: str = Field(..., min_length=8, max_length=100)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for updating user profile."""
|
||||
|
||||
display_name: Optional[str] = Field(None, max_length=100)
|
||||
avatar_url: Optional[str] = None
|
||||
date_of_birth: Optional[datetime] = None
|
||||
country: Optional[str] = Field(None, max_length=2)
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
"""Schema for user response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
display_name: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
is_premium: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for JWT token response."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Schema for JWT token payload."""
|
||||
|
||||
sub: str # user id
|
||||
exp: int # expiration timestamp
|
||||
type: str # "access" or "refresh"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Schema for login request."""
|
||||
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Schema for token refresh request."""
|
||||
|
||||
refresh_token: str
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Music schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class ArtistBase(BaseModel):
|
||||
"""Base artist schema."""
|
||||
|
||||
name: str
|
||||
image_url: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
genres: List[str] = Field(default_factory=list)
|
||||
popularity: int = 0
|
||||
|
||||
|
||||
class ArtistResponse(ArtistBase):
|
||||
"""Schema for artist response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
spotify_id: Optional[str] = None
|
||||
youtube_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AlbumBase(BaseModel):
|
||||
"""Base album schema."""
|
||||
|
||||
title: str
|
||||
release_date: Optional[datetime] = None
|
||||
image_url: Optional[str] = None
|
||||
total_tracks: int = 0
|
||||
genre: Optional[str] = None
|
||||
|
||||
|
||||
class AlbumResponse(AlbumBase):
|
||||
"""Schema for album response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
artist_id: Optional[UUID] = None
|
||||
spotify_id: Optional[str] = None
|
||||
youtube_playlist_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TrackBase(BaseModel):
|
||||
"""Base track schema."""
|
||||
|
||||
title: str
|
||||
duration: Optional[int] = Field(None, description="Duration in seconds")
|
||||
track_number: Optional[int] = None
|
||||
disc_number: int = 1
|
||||
image_url: Optional[str] = None
|
||||
genre: Optional[str] = None
|
||||
mood: Optional[str] = None
|
||||
|
||||
|
||||
class TrackResponse(TrackBase):
|
||||
"""Schema for track response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
artist_id: Optional[UUID] = None
|
||||
album_id: Optional[UUID] = None
|
||||
artist: Optional[ArtistResponse] = None
|
||||
album: Optional[AlbumResponse] = None
|
||||
audio_url: Optional[str] = None
|
||||
audio_quality: Optional[str] = None
|
||||
play_count: int = 0
|
||||
spotify_id: Optional[str] = None
|
||||
youtube_id: Optional[str] = None
|
||||
soundcloud_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TrackSearchResult(BaseModel):
|
||||
"""Schema for track search result."""
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
duration: Optional[int] = None
|
||||
image_url: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
album: Optional[str] = None
|
||||
audio_url: Optional[str] = None
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""Schema for search request."""
|
||||
|
||||
query: str = Field(..., min_length=1, max_length=100)
|
||||
type: Optional[str] = Field("track", pattern="^(track|artist|album|all)$")
|
||||
limit: int = Field(20, ge=1, le=100)
|
||||
offset: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Schema for search response."""
|
||||
|
||||
tracks: List[TrackSearchResult] = Field(default_factory=list)
|
||||
artists: List[ArtistResponse] = Field(default_factory=list)
|
||||
albums: List[AlbumResponse] = Field(default_factory=list)
|
||||
total: int
|
||||
query: str
|
||||
|
||||
|
||||
class StreamUrlResponse(BaseModel):
|
||||
"""Schema for stream URL response."""
|
||||
|
||||
url: str
|
||||
format: str = "audio/mpeg"
|
||||
duration: Optional[int] = None
|
||||
|
||||
|
||||
class YouTubeSearchResult(BaseModel):
|
||||
"""Schema for YouTube search result."""
|
||||
|
||||
youtube_id: str
|
||||
title: str
|
||||
artist: Optional[str] = None
|
||||
duration: Optional[int] = None
|
||||
thumbnail: Optional[str] = None
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Playlist schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class PlaylistBase(BaseModel):
|
||||
"""Base playlist schema."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
is_public: bool = False
|
||||
is_collaborative: bool = False
|
||||
|
||||
|
||||
class PlaylistCreate(PlaylistBase):
|
||||
"""Schema for creating a playlist."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PlaylistUpdate(BaseModel):
|
||||
"""Schema for updating a playlist."""
|
||||
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_collaborative: Optional[bool] = None
|
||||
|
||||
|
||||
class PlaylistResponse(PlaylistBase):
|
||||
"""Schema for playlist response."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
track_count: int
|
||||
total_duration: int
|
||||
is_smart: bool
|
||||
smart_rules: dict
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PlaylistWithTracks(PlaylistResponse):
|
||||
"""Schema for playlist with tracks."""
|
||||
|
||||
tracks: List[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AddTrackRequest(BaseModel):
|
||||
"""Schema for adding tracks to playlist."""
|
||||
|
||||
track_ids: List[UUID] = Field(..., min_length=1, max_length=100)
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
class ReorderTracksRequest(BaseModel):
|
||||
"""Schema for reordering tracks in playlist."""
|
||||
|
||||
track_id: UUID
|
||||
new_position: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class PlaylistTrackResponse(BaseModel):
|
||||
"""Schema for playlist track response."""
|
||||
|
||||
id: UUID
|
||||
playlist_id: UUID
|
||||
track_id: UUID
|
||||
position: int
|
||||
added_at: datetime
|
||||
added_by: Optional[UUID] = None
|
||||
track: Optional[dict] = None
|
||||
@@ -0,0 +1 @@
|
||||
"""Services module."""
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Authentication service."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def register(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
password: str,
|
||||
display_name: Optional[str] = None,
|
||||
) -> User:
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
username: Username
|
||||
password: Plain text password
|
||||
display_name: Optional display name
|
||||
|
||||
Returns:
|
||||
Created user
|
||||
|
||||
Raises:
|
||||
ValueError: If email or username already exists
|
||||
"""
|
||||
# Check if email exists
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
# Check if username exists
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise ValueError("Username already taken")
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=get_password_hash(password),
|
||||
display_name=display_name or username,
|
||||
)
|
||||
|
||||
self.db.add(user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
async def login(self, email: str, password: str) -> User:
|
||||
"""
|
||||
Authenticate user with email and password.
|
||||
|
||||
Args:
|
||||
email: User email
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Authenticated user
|
||||
|
||||
Raises:
|
||||
ValueError: If credentials are invalid
|
||||
"""
|
||||
# Find user by email
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.email == email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Verify password
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise ValueError("Invalid email or password")
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
|
||||
return user
|
||||
|
||||
async def get_user_by_id(self, user_id: UUID) -> Optional[User]:
|
||||
"""
|
||||
Get user by ID.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
User or None
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
user_id: UUID,
|
||||
display_name: Optional[str] = None,
|
||||
avatar_url: Optional[str] = None,
|
||||
date_of_birth: Optional[datetime] = None,
|
||||
country: Optional[str] = None,
|
||||
) -> User:
|
||||
"""
|
||||
Update user profile.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
display_name: Optional display name
|
||||
avatar_url: Optional avatar URL
|
||||
date_of_birth: Optional date of birth
|
||||
country: Optional country code
|
||||
|
||||
Returns:
|
||||
Updated user
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Update fields
|
||||
if display_name is not None:
|
||||
user.display_name = display_name
|
||||
if avatar_url is not None:
|
||||
user.avatar_url = avatar_url
|
||||
if date_of_birth is not None:
|
||||
user.date_of_birth = date_of_birth
|
||||
if country is not None:
|
||||
user.country = country
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
def create_tokens(self, user_id: UUID) -> tuple[str, str]:
|
||||
"""
|
||||
Create access and refresh tokens for user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, refresh_token)
|
||||
"""
|
||||
access_token = create_access_token(subject=str(user_id))
|
||||
refresh_token = create_refresh_token(subject=str(user_id))
|
||||
return access_token, refresh_token
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Music service."""
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.track import Track
|
||||
from app.models.artist import Artist
|
||||
from app.models.album import Album
|
||||
from app.services.youtube_service import YouTubeService
|
||||
|
||||
|
||||
class MusicService:
|
||||
"""Service for music operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.youtube = YouTubeService()
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
search_type: str = "all",
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Search for music across database and YouTube.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
search_type: Type of content (track, artist, album, all)
|
||||
limit: Maximum results
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
Search results with tracks, artists, albums
|
||||
"""
|
||||
results = {
|
||||
"tracks": [],
|
||||
"artists": [],
|
||||
"albums": [],
|
||||
"total": 0,
|
||||
"query": query,
|
||||
}
|
||||
|
||||
# Search database first
|
||||
if search_type in ["track", "all"]:
|
||||
results["tracks"] = await self._search_tracks(query, limit)
|
||||
results["total"] += len(results["tracks"])
|
||||
|
||||
if search_type in ["artist", "all"]:
|
||||
results["artists"] = await self._search_artists(query, limit)
|
||||
results["total"] += len(results["artists"])
|
||||
|
||||
if search_type in ["album", "all"]:
|
||||
results["albums"] = await self._search_albums(query, limit)
|
||||
results["total"] += len(results["albums"])
|
||||
|
||||
# If no local results, search YouTube
|
||||
if results["total"] == 0:
|
||||
yt_results = await self.youtube.search(query, max_results=limit)
|
||||
results["tracks"] = yt_results[:limit]
|
||||
|
||||
return results
|
||||
|
||||
async def _search_tracks(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search tracks in database."""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.options(selectinload(Track.artist), selectinload(Track.album))
|
||||
.where(
|
||||
or_(
|
||||
Track.title.ilike(f"%{query}%"),
|
||||
)
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(track.id),
|
||||
"title": track.title,
|
||||
"duration": track.duration,
|
||||
"image_url": track.image_url,
|
||||
"artist": track.artist.name if track.artist else None,
|
||||
"album": track.album.title if track.album else None,
|
||||
"youtube_id": track.youtube_id,
|
||||
}
|
||||
for track in tracks
|
||||
]
|
||||
|
||||
async def _search_artists(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search artists in database."""
|
||||
stmt = (
|
||||
select(Artist)
|
||||
.where(Artist.name.ilike(f"%{query}%"))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
artists = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(artist.id),
|
||||
"name": artist.name,
|
||||
"image_url": artist.image_url,
|
||||
"genres": artist.genres,
|
||||
"popularity": artist.popularity,
|
||||
}
|
||||
for artist in artists
|
||||
]
|
||||
|
||||
async def _search_albums(self, query: str, limit: int) -> List[dict]:
|
||||
"""Search albums in database."""
|
||||
stmt = (
|
||||
select(Album)
|
||||
.options(selectinload(Album.artist))
|
||||
.where(Album.title.ilike(f"%{query}%"))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
albums = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(album.id),
|
||||
"title": album.title,
|
||||
"image_url": album.image_url,
|
||||
"artist": album.artist.name if album.artist else None,
|
||||
"total_tracks": album.total_tracks,
|
||||
"release_date": album.release_date.isoformat() if album.release_date else None,
|
||||
}
|
||||
for album in albums
|
||||
]
|
||||
|
||||
async def get_track(self, track_id: UUID) -> Optional[Track]:
|
||||
"""
|
||||
Get track by ID.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
|
||||
Returns:
|
||||
Track or None
|
||||
"""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.options(selectinload(Track.artist), selectinload(Track.album))
|
||||
.where(Track.id == track_id)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_stream_url(
|
||||
self,
|
||||
track_id: UUID,
|
||||
quality: str = "high",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get stream URL for a track.
|
||||
|
||||
Args:
|
||||
track_id: Track UUID
|
||||
quality: Audio quality
|
||||
|
||||
Returns:
|
||||
Stream URL or None
|
||||
"""
|
||||
track = await self.get_track(track_id)
|
||||
if not track or not track.youtube_id:
|
||||
return None
|
||||
|
||||
# Try to get direct stream URL from YouTube
|
||||
stream_url = await self.youtube.get_stream_url(track.youtube_id)
|
||||
if stream_url:
|
||||
return stream_url
|
||||
|
||||
# Fallback: download and serve locally
|
||||
cache_path = await self.youtube.download_audio(track.youtube_id, quality)
|
||||
if cache_path:
|
||||
# In production, you'd serve this through a dedicated endpoint
|
||||
return f"/api/v1/music/tracks/{track_id}/stream?cache=true"
|
||||
|
||||
return None
|
||||
|
||||
async def create_track_from_youtube(
|
||||
self,
|
||||
youtube_id: str,
|
||||
title: str,
|
||||
artist_name: Optional[str] = None,
|
||||
album_name: Optional[str] = None,
|
||||
) -> Track:
|
||||
"""
|
||||
Create a track from YouTube video ID.
|
||||
|
||||
Args:
|
||||
youtube_id: YouTube video ID
|
||||
title: Track title
|
||||
artist_name: Optional artist name
|
||||
album_name: Optional album name
|
||||
|
||||
Returns:
|
||||
Created track
|
||||
"""
|
||||
# Get video info from YouTube
|
||||
video_info = await self.youtube.get_video_info(youtube_id)
|
||||
if video_info:
|
||||
title = video_info.get("title", title)
|
||||
artist_name = artist_name or video_info.get("artist")
|
||||
duration = video_info.get("duration")
|
||||
thumbnail = video_info.get("thumbnail")
|
||||
else:
|
||||
duration = None
|
||||
thumbnail = None
|
||||
|
||||
# Find or create artist
|
||||
artist = None
|
||||
if artist_name:
|
||||
stmt = select(Artist).where(Artist.name == artist_name)
|
||||
result = await self.db.execute(stmt)
|
||||
artist = result.scalar_one_or_none()
|
||||
|
||||
if not artist:
|
||||
artist = Artist(
|
||||
name=artist_name,
|
||||
image_url=thumbnail,
|
||||
)
|
||||
self.db.add(artist)
|
||||
await self.db.flush()
|
||||
|
||||
# Create track
|
||||
track = Track(
|
||||
title=title,
|
||||
youtube_id=youtube_id,
|
||||
artist_id=artist.id if artist else None,
|
||||
duration=duration,
|
||||
image_url=thumbnail,
|
||||
)
|
||||
|
||||
self.db.add(track)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(track)
|
||||
|
||||
return track
|
||||
|
||||
async def get_recommendations(
|
||||
self,
|
||||
track_id: UUID,
|
||||
limit: int = 10,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Get recommendations based on a track.
|
||||
|
||||
Args:
|
||||
track_id: Seed track UUID
|
||||
limit: Number of recommendations
|
||||
|
||||
Returns:
|
||||
List of recommended tracks
|
||||
"""
|
||||
track = await self.get_track(track_id)
|
||||
if not track or not track.youtube_id:
|
||||
return []
|
||||
|
||||
# Get related videos from YouTube
|
||||
related = await self.youtube.get_related_videos(track.youtube_id, max_results=limit)
|
||||
|
||||
return related[:limit]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Playlist service."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.playlist_track import PlaylistTrack
|
||||
from app.models.track import Track
|
||||
|
||||
|
||||
class PlaylistService:
|
||||
"""Service for playlist operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_playlist(
|
||||
self,
|
||||
user_id: UUID,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
is_public: bool = False,
|
||||
is_collaborative: bool = False,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Create a new playlist.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
name: Playlist name
|
||||
description: Optional description
|
||||
image_url: Optional cover image URL
|
||||
is_public: Whether playlist is public
|
||||
is_collaborative: Whether playlist is collaborative
|
||||
|
||||
Returns:
|
||||
Created playlist
|
||||
"""
|
||||
playlist = Playlist(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
image_url=image_url,
|
||||
is_public=is_public,
|
||||
is_collaborative=is_collaborative,
|
||||
track_count=0,
|
||||
total_duration=0,
|
||||
)
|
||||
|
||||
self.db.add(playlist)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
include_tracks: bool = False,
|
||||
) -> Optional[Playlist]:
|
||||
"""
|
||||
Get playlist by ID.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
include_tracks: Whether to include tracks
|
||||
|
||||
Returns:
|
||||
Playlist or None
|
||||
"""
|
||||
stmt = select(Playlist).where(Playlist.id == playlist_id)
|
||||
|
||||
if include_tracks:
|
||||
stmt = stmt.options(selectinload(Playlist.playlist_tracks))
|
||||
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_playlists(
|
||||
self,
|
||||
user_id: UUID,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Playlist]:
|
||||
"""
|
||||
Get all playlists for a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
limit: Maximum results
|
||||
offset: Pagination offset
|
||||
|
||||
Returns:
|
||||
List of playlists
|
||||
"""
|
||||
stmt = (
|
||||
select(Playlist)
|
||||
.where(Playlist.user_id == user_id)
|
||||
.order_by(Playlist.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
user_id: UUID,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
is_public: Optional[bool] = None,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Update playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
user_id: User UUID (for ownership check)
|
||||
name: New name
|
||||
description: New description
|
||||
image_url: New image URL
|
||||
is_public: New public status
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found or user not owner
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to update this playlist")
|
||||
|
||||
if name is not None:
|
||||
playlist.name = name
|
||||
if description is not None:
|
||||
playlist.description = description
|
||||
if image_url is not None:
|
||||
playlist.image_url = image_url
|
||||
if is_public is not None:
|
||||
playlist.is_public = is_public
|
||||
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def delete_playlist(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
Delete a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found or user not owner
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to delete this playlist")
|
||||
|
||||
await self.db.delete(playlist)
|
||||
await self.db.commit()
|
||||
|
||||
async def add_tracks(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_ids: List[UUID],
|
||||
user_id: UUID,
|
||||
position: Optional[int] = None,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Add tracks to a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_ids: List of track UUIDs
|
||||
user_id: User UUID adding the tracks
|
||||
position: Optional starting position
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
# Get current max position
|
||||
stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
last_track = result.scalar_one_or_none()
|
||||
max_position = last_track.position if last_track else -1
|
||||
|
||||
# Determine starting position
|
||||
if position is None:
|
||||
position = max_position + 1
|
||||
|
||||
# Add tracks
|
||||
current_position = position
|
||||
for track_id in track_ids:
|
||||
# Verify track exists
|
||||
track_stmt = select(Track).where(Track.id == track_id)
|
||||
track_result = await self.db.execute(track_stmt)
|
||||
track = track_result.scalar_one_or_none()
|
||||
|
||||
if not track:
|
||||
continue
|
||||
|
||||
# Create playlist track
|
||||
playlist_track = PlaylistTrack(
|
||||
playlist_id=playlist_id,
|
||||
track_id=track_id,
|
||||
position=current_position,
|
||||
added_by=user_id,
|
||||
)
|
||||
self.db.add(playlist_track)
|
||||
current_position += 1
|
||||
|
||||
# Update playlist stats
|
||||
playlist.track_count += len(track_ids)
|
||||
playlist.total_duration = await self._calculate_playlist_duration(playlist_id)
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def remove_track(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_id: UUID,
|
||||
user_id: UUID,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Remove a track from a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_id: Track UUID to remove
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist or track not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to modify this playlist")
|
||||
|
||||
# Find and remove the track
|
||||
stmt = select(PlaylistTrack).where(
|
||||
PlaylistTrack.playlist_id == playlist_id,
|
||||
PlaylistTrack.track_id == track_id,
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
playlist_track = result.scalar_one_or_none()
|
||||
|
||||
if not playlist_track:
|
||||
raise ValueError("Track not in playlist")
|
||||
|
||||
# Remove track
|
||||
await self.db.delete(playlist_track)
|
||||
|
||||
# Reorder remaining tracks
|
||||
tracks_stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position)
|
||||
)
|
||||
tracks_result = await self.db.execute(tracks_stmt)
|
||||
tracks = tracks_result.scalars().all()
|
||||
|
||||
for index, track in enumerate(tracks):
|
||||
track.position = index
|
||||
|
||||
# Update playlist stats
|
||||
playlist.track_count -= 1
|
||||
playlist.total_duration = await self._calculate_playlist_duration(playlist_id)
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def reorder_track(
|
||||
self,
|
||||
playlist_id: UUID,
|
||||
track_id: UUID,
|
||||
new_position: int,
|
||||
user_id: UUID,
|
||||
) -> Playlist:
|
||||
"""
|
||||
Reorder a track within a playlist.
|
||||
|
||||
Args:
|
||||
playlist_id: Playlist UUID
|
||||
track_id: Track UUID to reorder
|
||||
new_position: New position (0-indexed)
|
||||
user_id: User UUID (for ownership check)
|
||||
|
||||
Returns:
|
||||
Updated playlist
|
||||
|
||||
Raises:
|
||||
ValueError: If playlist or track not found
|
||||
"""
|
||||
playlist = await self.get_playlist(playlist_id)
|
||||
if not playlist:
|
||||
raise ValueError("Playlist not found")
|
||||
|
||||
if playlist.user_id != user_id:
|
||||
raise ValueError("Not authorized to modify this playlist")
|
||||
|
||||
# Get all tracks in playlist
|
||||
stmt = (
|
||||
select(PlaylistTrack)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
.order_by(PlaylistTrack.position)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = list(result.scalars().all())
|
||||
|
||||
# Find the track to move
|
||||
track_to_move = None
|
||||
for track in tracks:
|
||||
if track.track_id == track_id:
|
||||
track_to_move = track
|
||||
break
|
||||
|
||||
if not track_to_move:
|
||||
raise ValueError("Track not in playlist")
|
||||
|
||||
# Reorder
|
||||
old_position = track_to_move.position
|
||||
if old_position < new_position:
|
||||
# Moving down: shift tracks between old+1 and new up by 1
|
||||
for track in tracks:
|
||||
if old_position < track.position <= new_position:
|
||||
track.position -= 1
|
||||
else:
|
||||
# Moving up: shift tracks between new and old-1 down by 1
|
||||
for track in tracks:
|
||||
if new_position <= track.position < old_position:
|
||||
track.position += 1
|
||||
|
||||
# Set new position
|
||||
track_to_move.position = new_position
|
||||
|
||||
playlist.updated_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def _calculate_playlist_duration(self, playlist_id: UUID) -> int:
|
||||
"""Calculate total duration of a playlist in seconds."""
|
||||
stmt = (
|
||||
select(Track)
|
||||
.join(PlaylistTrack, Track.id == PlaylistTrack.track_id)
|
||||
.where(PlaylistTrack.playlist_id == playlist_id)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
tracks = result.scalars().all()
|
||||
|
||||
total_duration = sum(
|
||||
track.duration for track in tracks if track.duration is not None
|
||||
)
|
||||
return total_duration
|
||||
@@ -0,0 +1,295 @@
|
||||
"""YouTube service using yt-dlp."""
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class YouTubeService:
|
||||
"""Service for YouTube operations using yt-dlp."""
|
||||
|
||||
def __init__(self):
|
||||
self.ytdlp_path = settings.YTDLP_PATH
|
||||
self.cache_dir = Path(settings.AUDIO_CACHE_PATH)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
max_results: int = 20,
|
||||
search_type: str = "videos",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search YouTube for videos.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
max_results: Maximum number of results
|
||||
search_type: Type of search (videos, playlists, etc.)
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
"ytsearch" + str(max_results) + ":" + query,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--skip-download",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
print(f"yt-dlp search error: {error_msg}")
|
||||
return []
|
||||
|
||||
# Parse JSON output (one line per video)
|
||||
results = []
|
||||
for line in stdout.decode().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
results.append(self._parse_search_result(data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error searching YouTube: {e}")
|
||||
return []
|
||||
|
||||
def _parse_search_result(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse yt-dlp search result."""
|
||||
return {
|
||||
"youtube_id": data.get("id", ""),
|
||||
"title": data.get("title", ""),
|
||||
"artist": data.get("artist", data.get("uploader", "")),
|
||||
"duration": self._parse_duration(data.get("duration")),
|
||||
"thumbnail": data.get("thumbnail"),
|
||||
"url": f"https://www.youtube.com/watch?v={data.get('id', '')}",
|
||||
}
|
||||
|
||||
def _parse_duration(self, duration: Optional[int]) -> Optional[int]:
|
||||
"""Parse duration in seconds."""
|
||||
if duration is None:
|
||||
return None
|
||||
return int(duration)
|
||||
|
||||
async def get_video_info(self, video_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed information about a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Video information or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--dump-json",
|
||||
"--skip-download",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
data = json.loads(stdout.decode())
|
||||
return self._parse_video_info(data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting video info: {e}")
|
||||
return None
|
||||
|
||||
def _parse_video_info(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse yt-dlp video info."""
|
||||
return {
|
||||
"youtube_id": data.get("id", ""),
|
||||
"title": data.get("title", ""),
|
||||
"artist": data.get("artist", data.get("uploader", "")),
|
||||
"album": data.get("album", ""),
|
||||
"duration": self._parse_duration(data.get("duration")),
|
||||
"thumbnail": data.get("thumbnail"),
|
||||
"description": data.get("description"),
|
||||
"genres": data.get("genres", []),
|
||||
"upload_date": data.get("upload_date"),
|
||||
}
|
||||
|
||||
async def get_stream_url(self, video_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get direct stream URL for a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Stream URL or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestaudio[ext=m4a]/bestaudio/best",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return None
|
||||
|
||||
stream_url = stdout.decode().strip()
|
||||
return stream_url if stream_url else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting stream URL: {e}")
|
||||
return None
|
||||
|
||||
async def download_audio(
|
||||
self,
|
||||
video_id: str,
|
||||
quality: str = "high",
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
Download audio from YouTube and cache it.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
quality: Audio quality (low, medium, high)
|
||||
|
||||
Returns:
|
||||
Path to downloaded file or None
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
cache_path = self.cache_dir / f"{video_id}.mp3"
|
||||
|
||||
# Check if already cached
|
||||
if cache_path.exists():
|
||||
return cache_path
|
||||
|
||||
# Determine format based on quality
|
||||
if quality == "high":
|
||||
audio_format = "320"
|
||||
elif quality == "medium":
|
||||
audio_format = "192"
|
||||
else:
|
||||
audio_format = "128"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--extract-audio",
|
||||
"--audio-format",
|
||||
"mp3",
|
||||
"--audio-quality",
|
||||
audio_format,
|
||||
"--output",
|
||||
str(cache_path),
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
print(f"Error downloading audio: {error_msg}")
|
||||
return None
|
||||
|
||||
return cache_path if cache_path.exists() else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading audio: {e}")
|
||||
return None
|
||||
|
||||
async def get_related_videos(self, video_id: str, max_results: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get related videos for a video.
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
max_results: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of related videos
|
||||
"""
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
cmd = [
|
||||
self.ytdlp_path,
|
||||
url,
|
||||
"--flat-playlist",
|
||||
"--playlist-end",
|
||||
str(max_results),
|
||||
"--dump-json",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for line in stdout.decode().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("id") != video_id: # Exclude the video itself
|
||||
results.append(self._parse_search_result(data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting related videos: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,52 @@
|
||||
# FastAPI and server
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.25
|
||||
asyncpg==0.30.0
|
||||
alembic==1.13.1
|
||||
|
||||
# Cache
|
||||
redis==5.2.1
|
||||
hiredis==3.1.0
|
||||
|
||||
# Validation and settings
|
||||
pydantic==2.10.6
|
||||
pydantic-settings==2.7.1
|
||||
email-validator==2.1.1
|
||||
|
||||
# Security
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# YouTube and streaming
|
||||
yt-dlp==2023.12.30
|
||||
|
||||
# HTTP client
|
||||
httpx==0.26.0
|
||||
|
||||
# Background tasks
|
||||
celery==5.3.6
|
||||
flower==2.0.1
|
||||
|
||||
# OAuth
|
||||
authlib==1.3.0
|
||||
|
||||
# Utils
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Development
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
black==24.1.1
|
||||
ruff==0.1.14
|
||||
mypy==1.8.0
|
||||
|
||||
# Spotify API (for import)
|
||||
spotipy==2.23.0
|
||||
|
||||
# Last.fm API (optional, for metadata)
|
||||
pylast==5.2.0
|
||||
Reference in New Issue
Block a user