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:
root
2026-01-18 20:08:36 +00:00
commit a89c7894cf
132 changed files with 23178 additions and 0 deletions
+114
View File
@@ -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
+78
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Application package."""
+1
View File
@@ -0,0 +1 @@
"""API module."""
+100
View File
@@ -0,0 +1,100 @@
"""API dependencies."""
from typing import Annotated, Optional
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
from app.services.auth_service import AuthService
# Database session dependency
DBSession = Annotated[AsyncSession, Depends(get_db)]
# Authentication
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: DBSession,
) -> User:
"""
Get current authenticated user from JWT token.
Args:
credentials: HTTP Authorization credentials
db: Database session
Returns:
Current user
Raises:
HTTPException: If token is invalid or user not found
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(credentials.credentials)
user_id: str = payload.get("sub")
token_type: str = payload.get("type")
if user_id is None or token_type != "access":
raise credentials_exception
except JWTError:
raise credentials_exception
auth_service = AuthService(db)
user = await auth_service.get_user_by_id(user_id)
if user is None:
raise credentials_exception
return user
async def get_current_user_optional(
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security)],
db: DBSession,
) -> Optional[User]:
"""
Get current user if authenticated, otherwise None.
Args:
credentials: Optional HTTP Authorization credentials
db: Database session
Returns:
Current user or None
"""
if credentials is None:
return None
try:
return await get_current_user(credentials, db) # type: ignore
except HTTPException:
return None
# Current user dependencies
CurrentUser = Annotated[User, Depends(get_current_user)]
CurrentUserOptional = Annotated[Optional[User], Depends(get_current_user_optional)]
def get_auth_service(db: DBSession) -> AuthService:
"""Get auth service instance."""
return AuthService(db)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
+1
View File
@@ -0,0 +1 @@
"""API v1 module."""
+178
View File
@@ -0,0 +1,178 @@
"""Authentication API routes."""
from fastapi import APIRouter, HTTPException, status
from app.api.dependencies import AuthServiceDep, CurrentUser, DBSession
from app.schemas.auth import (
LoginRequest,
RefreshTokenRequest,
Token,
UserCreate,
UserResponse,
UserUpdate,
)
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
auth_service: AuthServiceDep,
):
"""
Register a new user.
- **email**: Valid email address
- **username**: 3-50 characters, unique
- **password**: Min 8 characters
- **display_name**: Optional display name
"""
try:
user = await auth_service.register(
email=user_data.email,
username=user_data.username,
password=user_data.password,
display_name=user_data.display_name,
)
return UserResponse.model_validate(user)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.post("/login", response_model=Token)
async def login(
credentials: LoginRequest,
auth_service: AuthServiceDep,
):
"""
Login with email and password.
Returns access and refresh tokens.
"""
try:
user = await auth_service.login(
email=credentials.email,
password=credentials.password,
)
access_token, refresh_token = auth_service.create_tokens(user.id)
return Token(
access_token=access_token,
refresh_token=refresh_token,
expires_in=15 * 60, # 15 minutes
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)
@router.post("/refresh", response_model=Token)
async def refresh_token(
token_data: RefreshTokenRequest,
auth_service: AuthServiceDep,
):
"""
Refresh access token using refresh token.
Returns new access and refresh tokens.
"""
from app.core.security import decode_token
try:
payload = decode_token(token_data.refresh_token)
user_id = payload.get("sub")
token_type = payload.get("type")
if user_id is None or token_type != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Verify user still exists
user = await auth_service.get_user_by_id(user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
# Create new tokens
access_token, refresh_token = auth_service.create_tokens(user.id)
return Token(
access_token=access_token,
refresh_token=refresh_token,
expires_in=15 * 60,
)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
@router.get("/me", response_model=UserResponse)
async def get_current_user(
current_user: CurrentUser,
):
"""
Get current authenticated user profile.
Requires authentication.
"""
return UserResponse.model_validate(current_user)
@router.put("/me", response_model=UserResponse)
async def update_current_user(
user_data: UserUpdate,
current_user: CurrentUser,
auth_service: AuthServiceDep,
):
"""
Update current user profile.
Requires authentication.
"""
try:
updated_user = await auth_service.update_user(
user_id=current_user.id,
display_name=user_data.display_name,
avatar_url=user_data.avatar_url,
date_of_birth=user_data.date_of_birth,
country=user_data.country,
)
return UserResponse.model_validate(updated_user)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
current_user: CurrentUser,
):
"""
Logout current user.
In a stateless JWT setup, this is mainly for client-side cleanup.
The token will expire automatically.
Requires authentication.
"""
# In production, you might want to:
# - Add token to blacklist (Redis)
# - Remove refresh token from database
# - Log the logout event
return None
+227
View File
@@ -0,0 +1,227 @@
"""Music API routes."""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies import CurrentUser, CurrentUserOptional, DBSession
from app.schemas.music import (
AlbumResponse,
SearchRequest,
SearchResponse,
StreamUrlResponse,
TrackResponse,
TrackSearchResult,
YouTubeSearchResult,
)
from app.services.music_service import MusicService
router = APIRouter(prefix="/music", tags=["music"])
@router.get("/search", response_model=SearchResponse)
async def search_music(
db: DBSession,
q: str = Query(..., min_length=1, max_length=100, description="Search query"),
type: str = Query("track", pattern="^(track|artist|album|all)$"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""
Search for music across database and YouTube.
- **q**: Search query
- **type**: Content type (track, artist, album, all)
- **limit**: Maximum results (1-100)
- **offset**: Pagination offset
"""
music_service = MusicService(db)
results = await music_service.search(
query=q,
search_type=type,
limit=limit,
offset=offset,
)
return SearchResponse(
tracks=[TrackSearchResult(**t) for t in results["tracks"]],
artists=[AlbumResponse(**a) for a in results["artists"]],
albums=[AlbumResponse(**a) for a in results["albums"]],
total=results["total"],
query=results["query"],
)
@router.get("/tracks/{track_id}", response_model=TrackResponse)
async def get_track(
track_id: str,
db: DBSession,
):
"""
Get detailed information about a track.
Requires authentication for full details.
"""
from uuid import UUID
music_service = MusicService(db)
try:
track = await music_service.get_track(UUID(track_id))
if not track:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Track not found",
)
return TrackResponse.model_validate(track)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid track ID",
)
@router.get("/tracks/{track_id}/stream")
async def stream_track(
track_id: str,
db: DBSession,
current_user: CurrentUserOptional = None,
cache: bool = Query(False, description="Use cached version if available"),
):
"""
Get stream URL for a track or stream directly.
Supports HTTP Range headers for proper streaming.
"""
from uuid import UUID
music_service = MusicService(db)
try:
track = await music_service.get_track(UUID(track_id))
if not track:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Track not found",
)
# Get stream URL
stream_url = await music_service.get_stream_url(UUID(track_id))
if stream_url and stream_url.startswith("/api"):
# Serve cached file
from pathlib import Path
cache_path = Path(f"./storage/audio/cache/{track.youtube_id}.mp3")
if cache_path.exists():
return FileResponse(
cache_path,
media_type="audio/mpeg",
filename=f"{track.title}.mp3",
)
if not stream_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Stream URL not available",
)
return StreamUrlResponse(
url=stream_url,
duration=track.duration,
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid track ID",
)
@router.post("/tracks/from-youtube", response_model=TrackResponse, status_code=status.HTTP_201_CREATED)
async def create_track_from_youtube(
youtube_id: str = Query(..., description="YouTube video ID"),
title: str = Query(..., description="Track title"),
artist: Optional[str] = Query(None, description="Artist name"),
album: Optional[str] = Query(None, description="Album name"),
db: DBSession = None,
current_user: CurrentUser = None,
):
"""
Create a track from a YouTube video.
Requires authentication.
- **youtube_id**: YouTube video ID
- **title**: Track title
- **artist**: Optional artist name
- **album**: Optional album name
"""
music_service = MusicService(db)
try:
track = await music_service.create_track_from_youtube(
youtube_id=youtube_id,
title=title,
artist_name=artist,
album_name=album,
)
return TrackResponse.model_validate(track)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create track: {str(e)}",
)
@router.get("/tracks/{track_id}/recommendations", response_model=list[YouTubeSearchResult])
async def get_track_recommendations(
track_id: str,
db: DBSession,
limit: int = Query(10, ge=1, le=50),
):
"""
Get recommendations based on a track.
Uses YouTube's related videos algorithm.
"""
from uuid import UUID
music_service = MusicService(db)
try:
recommendations = await music_service.get_recommendations(
UUID(track_id),
limit=limit,
)
return [YouTubeSearchResult(**r) for r in recommendations]
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid track ID",
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get recommendations: {str(e)}",
)
@router.get("/trending", response_model=list[TrackSearchResult])
async def get_trending(
db: DBSession,
limit: int = Query(20, ge=1, le=50),
):
"""
Get trending tracks.
Currently returns placeholder data.
In production, this would use actual trending data.
"""
music_service = MusicService(db)
# Search for popular music on YouTube
results = await music_service.search("music 2024", search_type="track", limit=limit)
return [TrackSearchResult(**t) for t in results["tracks"]]
+350
View File
@@ -0,0 +1,350 @@
"""Playlists API routes."""
from typing import List
from fastapi import APIRouter, HTTPException, Query, 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",
)
+1
View File
@@ -0,0 +1 @@
"""Core module."""
+158
View File
@@ -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()
+106
View File
@@ -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()
+124
View File
@@ -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
+125
View File
@@ -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(),
)
+16
View File
@@ -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",
]
+121
View File
@@ -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(),
}
+116
View File
@@ -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(),
}
+133
View File
@@ -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
+96
View File
@@ -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(),
}
+165
View File
@@ -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(),
}
+121
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
"""Schemas module."""
+71
View File
@@ -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
+132
View File
@@ -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
+79
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Services module."""
+182
View File
@@ -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
+273
View File
@@ -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]
+402
View File
@@ -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
+295
View File
@@ -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 []
+52
View File
@@ -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