This commit is contained in:
2025-12-12 13:30:09 +03:00
commit 2f1e1f35e3
75 changed files with 4603 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

32
backend/app/config.py Normal file
View File

@@ -0,0 +1,32 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
database_url: str = "postgresql://postgres:postgres@localhost:5432/enigfm"
# JWT
secret_key: str = "your-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# S3 (FirstVDS)
s3_endpoint_url: str = ""
s3_access_key: str = ""
s3_secret_key: str = ""
s3_bucket_name: str = "enigfm"
s3_region: str = "ru-1"
# Limits
max_file_size_mb: int = 10
max_storage_gb: int = 90
max_room_participants: int = 50
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
return Settings()

27
backend/app/database.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from .config import get_settings
settings = get_settings()
# Convert postgresql:// to postgresql+asyncpg://
database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

31
backend/app/main.py Normal file
View File

@@ -0,0 +1,31 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import auth, rooms, tracks, websocket, messages
app = FastAPI(title="EnigFM", description="Listen to music together with friends")
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(auth.router)
app.include_router(rooms.router)
app.include_router(tracks.router)
app.include_router(messages.router)
app.include_router(websocket.router)
@app.get("/")
async def root():
return {"message": "EnigFM API", "version": "1.0.0"}
@app.get("/health")
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,6 @@
from .user import User
from .room import Room, RoomParticipant
from .track import Track, RoomQueue
from .message import Message
__all__ = ["User", "Room", "RoomParticipant", "Track", "RoomQueue", "Message"]

View File

@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..database import Base
class Message(Base):
__tablename__ = "messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rooms.id"), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
room = relationship("Room", back_populates="messages")
user = relationship("User", back_populates="messages")

View File

@@ -0,0 +1,38 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..database import Base
class Room(Base):
__tablename__ = "rooms"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(100), nullable=False)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
current_track_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tracks.id"), nullable=True)
playback_position: Mapped[int] = mapped_column(Integer, default=0) # milliseconds
playback_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # when playback started
is_playing: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
owner = relationship("User", back_populates="owned_rooms")
current_track = relationship("Track", foreign_keys=[current_track_id])
participants = relationship("RoomParticipant", back_populates="room", cascade="all, delete-orphan")
queue = relationship("RoomQueue", back_populates="room", cascade="all, delete-orphan", order_by="RoomQueue.position")
messages = relationship("Message", back_populates="room", cascade="all, delete-orphan")
class RoomParticipant(Base):
__tablename__ = "room_participants"
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rooms.id"), primary_key=True)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True)
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
room = relationship("Room", back_populates="participants")
user = relationship("User", back_populates="room_participations")

View File

@@ -0,0 +1,38 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..database import Base
class Track(Base):
__tablename__ = "tracks"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(255), nullable=False)
artist: Mapped[str] = mapped_column(String(255), nullable=False)
duration: Mapped[int] = mapped_column(Integer, nullable=False) # milliseconds
s3_key: Mapped[str] = mapped_column(String(500), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False) # bytes
uploaded_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
uploader = relationship("User", back_populates="uploaded_tracks")
queue_entries = relationship("RoomQueue", back_populates="track", cascade="all, delete-orphan")
class RoomQueue(Base):
__tablename__ = "room_queue"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rooms.id"), nullable=False)
track_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tracks.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
added_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# Relationships
room = relationship("Room", back_populates="queue")
track = relationship("Track", back_populates="queue_entries")
added_by_user = relationship("User")

View File

@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
owned_rooms = relationship("Room", back_populates="owner", cascade="all, delete-orphan")
uploaded_tracks = relationship("Track", back_populates="uploader")
messages = relationship("Message", back_populates="user")
room_participations = relationship("RoomParticipant", back_populates="user", cascade="all, delete-orphan")

View File

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from ..database import get_db
from ..models.user import User
from ..schemas.user import UserCreate, UserLogin, UserResponse, Token
from ..utils.security import get_password_hash, verify_password, create_access_token
from ..services.auth import get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/register", response_model=Token)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if email exists
result = await db.execute(select(User).where(User.email == user_data.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Check if username exists
result = await db.execute(select(User).where(User.username == user_data.username))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken",
)
# Create user
user = User(
username=user_data.username,
email=user_data.email,
password_hash=get_password_hash(user_data.password),
)
db.add(user)
await db.flush()
# Create token
access_token = create_access_token(data={"sub": str(user.id)})
return Token(access_token=access_token)
@router.post("/login", response_model=Token)
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == user_data.email))
user = result.scalar_one_or_none()
if not user or not verify_password(user_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
access_token = create_access_token(data={"sub": str(user.id)})
return Token(access_token=access_token)
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user

View File

@@ -0,0 +1,38 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models.message import Message
from ..schemas.message import MessageResponse
router = APIRouter(prefix="/api/rooms", tags=["messages"])
@router.get("/{room_id}/messages", response_model=list[MessageResponse])
async def get_messages(
room_id: UUID,
limit: int = 50,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Message)
.options(selectinload(Message.user))
.where(Message.room_id == room_id)
.order_by(Message.created_at.desc())
.limit(limit)
)
messages = result.scalars().all()
return [
MessageResponse(
id=msg.id,
room_id=msg.room_id,
user_id=msg.user_id,
username=msg.user.username,
text=msg.text,
created_at=msg.created_at,
)
for msg in reversed(messages)
]

View File

@@ -0,0 +1,248 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models.user import User
from ..models.room import Room, RoomParticipant
from ..models.track import RoomQueue
from ..schemas.room import RoomCreate, RoomResponse, RoomDetailResponse, QueueAdd
from ..schemas.track import TrackResponse
from ..schemas.user import UserResponse
from ..services.auth import get_current_user
from ..services.sync import manager
from ..config import get_settings
settings = get_settings()
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
@router.get("", response_model=list[RoomResponse])
async def get_rooms(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Room, func.count(RoomParticipant.user_id).label("participants_count"))
.outerjoin(RoomParticipant)
.group_by(Room.id)
.order_by(Room.created_at.desc())
)
rooms = []
for room, count in result.all():
room_dict = {
"id": room.id,
"name": room.name,
"owner_id": room.owner_id,
"current_track_id": room.current_track_id,
"playback_position": room.playback_position,
"is_playing": room.is_playing,
"created_at": room.created_at,
"participants_count": count,
}
rooms.append(RoomResponse(**room_dict))
return rooms
@router.post("", response_model=RoomResponse)
async def create_room(
room_data: RoomCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
room = Room(name=room_data.name, owner_id=current_user.id)
db.add(room)
await db.flush()
return RoomResponse(
id=room.id,
name=room.name,
owner_id=room.owner_id,
current_track_id=room.current_track_id,
playback_position=room.playback_position,
is_playing=room.is_playing,
created_at=room.created_at,
participants_count=0,
)
@router.get("/{room_id}", response_model=RoomDetailResponse)
async def get_room(room_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Room)
.options(
selectinload(Room.owner),
selectinload(Room.current_track),
selectinload(Room.participants).selectinload(RoomParticipant.user),
)
.where(Room.id == room_id)
)
room = result.scalar_one_or_none()
if not room:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
return RoomDetailResponse(
id=room.id,
name=room.name,
owner=UserResponse.model_validate(room.owner),
current_track=TrackResponse.model_validate(room.current_track) if room.current_track else None,
playback_position=room.playback_position,
is_playing=room.is_playing,
created_at=room.created_at,
participants=[UserResponse.model_validate(p.user) for p in room.participants],
)
@router.delete("/{room_id}")
async def delete_room(
room_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Room).where(Room.id == room_id))
room = result.scalar_one_or_none()
if not room:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
if room.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not room owner")
await db.delete(room)
return {"status": "deleted"}
@router.post("/{room_id}/join")
async def join_room(
room_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Room).where(Room.id == room_id))
room = result.scalar_one_or_none()
if not room:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
# Check participant limit
result = await db.execute(
select(func.count(RoomParticipant.user_id)).where(RoomParticipant.room_id == room_id)
)
count = result.scalar()
if count >= settings.max_room_participants:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Room is full")
# Check if already joined
result = await db.execute(
select(RoomParticipant).where(
RoomParticipant.room_id == room_id,
RoomParticipant.user_id == current_user.id,
)
)
if result.scalar_one_or_none():
return {"status": "already joined"}
participant = RoomParticipant(room_id=room_id, user_id=current_user.id)
db.add(participant)
# Notify others
await manager.broadcast_to_room(
room_id,
{"type": "user_joined", "user": {"id": str(current_user.id), "username": current_user.username}},
)
return {"status": "joined"}
@router.post("/{room_id}/leave")
async def leave_room(
room_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RoomParticipant).where(
RoomParticipant.room_id == room_id,
RoomParticipant.user_id == current_user.id,
)
)
participant = result.scalar_one_or_none()
if participant:
await db.delete(participant)
# Notify others
await manager.broadcast_to_room(
room_id,
{"type": "user_left", "user_id": str(current_user.id)},
)
return {"status": "left"}
@router.get("/{room_id}/queue", response_model=list[TrackResponse])
async def get_queue(room_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(RoomQueue)
.options(selectinload(RoomQueue.track))
.where(RoomQueue.room_id == room_id)
.order_by(RoomQueue.position)
)
queue_items = result.scalars().all()
return [TrackResponse.model_validate(item.track) for item in queue_items]
@router.post("/{room_id}/queue")
async def add_to_queue(
room_id: UUID,
data: QueueAdd,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Get max position
result = await db.execute(
select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id)
)
max_pos = result.scalar() or 0
queue_item = RoomQueue(
room_id=room_id,
track_id=data.track_id,
position=max_pos + 1,
added_by=current_user.id,
)
db.add(queue_item)
await db.flush()
# Notify others
await manager.broadcast_to_room(
room_id,
{"type": "queue_updated"},
)
return {"status": "added"}
@router.delete("/{room_id}/queue/{track_id}")
async def remove_from_queue(
room_id: UUID,
track_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RoomQueue).where(
RoomQueue.room_id == room_id,
RoomQueue.track_id == track_id,
)
)
queue_item = result.scalar_one_or_none()
if queue_item:
await db.delete(queue_item)
# Notify others
await manager.broadcast_to_room(
room_id,
{"type": "queue_updated"},
)
return {"status": "removed"}

View File

@@ -0,0 +1,222 @@
import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from mutagen.mp3 import MP3
from io import BytesIO
from ..database import get_db
from ..models.user import User
from ..models.track import Track
from ..schemas.track import TrackResponse, TrackWithUrl
from ..services.auth import get_current_user
from ..services.s3 import upload_file, delete_file, generate_presigned_url, can_upload_file, get_file_content
from ..config import get_settings
settings = get_settings()
router = APIRouter(prefix="/api/tracks", tags=["tracks"])
@router.get("", response_model=list[TrackResponse])
async def get_tracks(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Track).order_by(Track.created_at.desc()))
return result.scalars().all()
@router.post("/upload", response_model=TrackResponse)
async def upload_track(
file: UploadFile = File(...),
title: str = Form(None),
artist: str = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Check file type
if not file.content_type or not file.content_type.startswith("audio/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an audio file",
)
# Read file content
content = await file.read()
file_size = len(content)
# Check file size
max_size = settings.max_file_size_mb * 1024 * 1024
if file_size > max_size:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds {settings.max_file_size_mb}MB limit",
)
# Check storage limit
if not await can_upload_file(file_size):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Storage limit exceeded",
)
# Get duration and metadata from MP3
try:
audio = MP3(BytesIO(content))
duration = int(audio.info.length * 1000) # Convert to milliseconds
# Extract ID3 tags if title/artist not provided
if not title or not artist:
tags = audio.tags
if tags:
# TIT2 = Title, TPE1 = Artist
if not title and tags.get("TIT2"):
title = str(tags.get("TIT2"))
if not artist and tags.get("TPE1"):
artist = str(tags.get("TPE1"))
# Fallback to filename if still no title
if not title:
title = file.filename.rsplit(".", 1)[0] if file.filename else "Unknown"
if not artist:
artist = "Unknown"
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not read audio file",
)
# Upload to S3
s3_key = f"tracks/{uuid.uuid4()}.mp3"
await upload_file(content, s3_key)
# Create track record
track = Track(
title=title,
artist=artist,
duration=duration,
s3_key=s3_key,
file_size=file_size,
uploaded_by=current_user.id,
)
db.add(track)
await db.flush()
return track
@router.get("/{track_id}", response_model=TrackWithUrl)
async def get_track(track_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Track).where(Track.id == track_id))
track = result.scalar_one_or_none()
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
url = generate_presigned_url(track.s3_key)
return TrackWithUrl(
id=track.id,
title=track.title,
artist=track.artist,
duration=track.duration,
file_size=track.file_size,
uploaded_by=track.uploaded_by,
created_at=track.created_at,
url=url,
)
@router.delete("/{track_id}")
async def delete_track(
track_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Track).where(Track.id == track_id))
track = result.scalar_one_or_none()
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
if track.uploaded_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not track owner")
# Delete from S3
await delete_file(track.s3_key)
# Delete from DB
await db.delete(track)
return {"status": "deleted"}
@router.get("/storage/info")
async def get_storage_info(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(func.sum(Track.file_size)))
total_size = result.scalar() or 0
max_size = settings.max_storage_gb * 1024 * 1024 * 1024
return {
"used_bytes": total_size,
"max_bytes": max_size,
"used_gb": round(total_size / (1024 * 1024 * 1024), 2),
"max_gb": settings.max_storage_gb,
}
@router.get("/{track_id}/stream")
async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
"""Stream audio file through backend with Range support (bypasses S3 SSL issues)"""
result = await db.execute(select(Track).where(Track.id == track_id))
track = result.scalar_one_or_none()
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
# Get full file content
content = get_file_content(track.s3_key)
file_size = len(content)
# Parse Range header
range_header = request.headers.get("range")
if range_header:
# Parse "bytes=start-end"
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
# Ensure valid range
if start >= file_size:
raise HTTPException(status_code=416, detail="Range not satisfiable")
end = min(end, file_size - 1)
content_length = end - start + 1
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
return Response(
content=content[start:end + 1],
status_code=206,
media_type="audio/mpeg",
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(content_length),
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
}
)
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
# No range - return full file
return Response(
content=content,
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
}
)

View File

@@ -0,0 +1,234 @@
from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import json
from datetime import datetime
from ..database import get_db, async_session
from ..models.room import Room, RoomParticipant
from ..models.track import RoomQueue
from ..models.message import Message
from ..models.user import User
from ..services.sync import manager
from ..utils.security import decode_token
router = APIRouter(tags=["websocket"])
async def get_user_from_token(token: str) -> User | None:
payload = decode_token(token)
if not payload:
return None
user_id = payload.get("sub")
if not user_id:
return None
async with async_session() as db:
result = await db.execute(select(User).where(User.id == UUID(user_id)))
return result.scalar_one_or_none()
@router.websocket("/ws/rooms/{room_id}")
async def room_websocket(websocket: WebSocket, room_id: UUID):
# Get token from query params
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=4001, reason="No token provided")
return
user = await get_user_from_token(token)
if not user:
await websocket.close(code=4001, reason="Invalid token")
return
await manager.connect(websocket, room_id, user.id)
try:
while True:
data = await websocket.receive_text()
message = json.loads(data)
async with async_session() as db:
if message["type"] == "player_action":
await handle_player_action(db, room_id, user, message)
elif message["type"] == "chat_message":
await handle_chat_message(db, room_id, user, message)
elif message["type"] == "sync_request":
await handle_sync_request(db, room_id, websocket)
except WebSocketDisconnect:
manager.disconnect(websocket, room_id, user.id)
await manager.broadcast_to_room(
room_id,
{"type": "user_left", "user_id": str(user.id)},
)
async def handle_player_action(db: AsyncSession, room_id: UUID, user: User, message: dict):
action = message.get("action")
result = await db.execute(select(Room).where(Room.id == room_id))
room = result.scalar_one_or_none()
if not room:
return
if action == "play":
room.is_playing = True
room.playback_position = message.get("position", room.playback_position or 0)
room.playback_started_at = datetime.utcnow()
elif action == "pause":
room.is_playing = False
room.playback_position = message.get("position", room.playback_position or 0)
room.playback_started_at = None
elif action == "seek":
room.playback_position = message.get("position", 0)
if room.is_playing:
room.playback_started_at = datetime.utcnow()
elif action == "next":
await play_next_track(db, room)
elif action == "prev":
await play_prev_track(db, room)
elif action == "set_track":
track_id = message.get("track_id")
if track_id:
room.current_track_id = UUID(track_id)
room.playback_position = 0
room.is_playing = True
room.playback_started_at = datetime.utcnow()
await db.commit()
# Get current track URL - use streaming endpoint to bypass S3 SSL issues
track_url = None
if room.current_track_id:
track_url = f"/api/tracks/{room.current_track_id}/stream"
# Calculate current position based on when playback started
current_position = room.playback_position or 0
if room.is_playing and room.playback_started_at:
elapsed = (datetime.utcnow() - room.playback_started_at).total_seconds() * 1000
current_position = int((room.playback_position or 0) + elapsed)
await manager.broadcast_to_room(
room_id,
{
"type": "player_state",
"is_playing": room.is_playing,
"position": current_position,
"current_track_id": str(room.current_track_id) if room.current_track_id else None,
"track_url": track_url,
"server_time": datetime.utcnow().isoformat(),
},
)
async def play_next_track(db: AsyncSession, room: Room):
result = await db.execute(
select(RoomQueue)
.where(RoomQueue.room_id == room.id)
.order_by(RoomQueue.position)
)
queue = result.scalars().all()
if not queue:
room.current_track_id = None
room.is_playing = False
room.playback_started_at = None
return
# Find current track in queue
current_index = -1
for i, item in enumerate(queue):
if item.track_id == room.current_track_id:
current_index = i
break
# Play next or first
next_index = (current_index + 1) % len(queue)
room.current_track_id = queue[next_index].track_id
room.playback_position = 0
room.is_playing = True
room.playback_started_at = datetime.utcnow()
async def play_prev_track(db: AsyncSession, room: Room):
result = await db.execute(
select(RoomQueue)
.where(RoomQueue.room_id == room.id)
.order_by(RoomQueue.position)
)
queue = result.scalars().all()
if not queue:
room.current_track_id = None
room.is_playing = False
room.playback_started_at = None
return
# Find current track in queue
current_index = 0
for i, item in enumerate(queue):
if item.track_id == room.current_track_id:
current_index = i
break
# Play prev or last
prev_index = (current_index - 1) % len(queue)
room.current_track_id = queue[prev_index].track_id
room.playback_position = 0
room.is_playing = True
room.playback_started_at = datetime.utcnow()
async def handle_chat_message(db: AsyncSession, room_id: UUID, user: User, message: dict):
text = message.get("text", "").strip()
if not text:
return
msg = Message(room_id=room_id, user_id=user.id, text=text)
db.add(msg)
await db.commit()
await manager.broadcast_to_room(
room_id,
{
"type": "chat_message",
"id": str(msg.id),
"user_id": str(user.id),
"username": user.username,
"text": text,
"created_at": msg.created_at.isoformat(),
},
)
async def handle_sync_request(db: AsyncSession, room_id: UUID, websocket: WebSocket):
result = await db.execute(
select(Room).options(selectinload(Room.current_track)).where(Room.id == room_id)
)
room = result.scalar_one_or_none()
if not room:
return
track_url = None
if room.current_track_id:
track_url = f"/api/tracks/{room.current_track_id}/stream"
# Calculate current position based on when playback started
current_position = room.playback_position or 0
if room.is_playing and room.playback_started_at:
elapsed = (datetime.utcnow() - room.playback_started_at).total_seconds() * 1000
current_position = int((room.playback_position or 0) + elapsed)
await websocket.send_json({
"type": "sync_state",
"is_playing": room.is_playing,
"position": current_position,
"current_track_id": str(room.current_track_id) if room.current_track_id else None,
"track_url": track_url,
"server_time": datetime.utcnow().isoformat(),
})

View File

View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
class MessageCreate(BaseModel):
text: str
class MessageResponse(BaseModel):
id: UUID
room_id: UUID
user_id: UUID
username: str
text: str
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,47 @@
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
from typing import Optional
from .user import UserResponse
from .track import TrackResponse
class RoomCreate(BaseModel):
name: str
class RoomResponse(BaseModel):
id: UUID
name: str
owner_id: UUID
current_track_id: Optional[UUID] = None
playback_position: int
is_playing: bool
created_at: datetime
participants_count: int = 0
class Config:
from_attributes = True
class RoomDetailResponse(BaseModel):
id: UUID
name: str
owner: UserResponse
current_track: Optional[TrackResponse] = None
playback_position: int
is_playing: bool
created_at: datetime
participants: list[UserResponse] = []
class Config:
from_attributes = True
class PlayerAction(BaseModel):
action: str # play, pause, seek, next, prev
position: Optional[int] = None # for seek
class QueueAdd(BaseModel):
track_id: UUID

View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
class TrackCreate(BaseModel):
title: str
artist: str
class TrackResponse(BaseModel):
id: UUID
title: str
artist: str
duration: int
file_size: int
uploaded_by: UUID
created_at: datetime
class Config:
from_attributes = True
class TrackWithUrl(TrackResponse):
url: str

View File

@@ -0,0 +1,33 @@
from pydantic import BaseModel, EmailStr
from uuid import UUID
from datetime import datetime
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: UUID
username: str
email: str
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: UUID | None = None

View File

View File

@@ -0,0 +1,42 @@
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from ..models.user import User
from ..database import get_db
from ..utils.security import decode_token
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
result = await db.execute(select(User).where(User.id == UUID(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user

View File

@@ -0,0 +1,77 @@
import boto3
import urllib3
from botocore.config import Config
from ..config import get_settings
# Suppress SSL warnings for self-signed certificate
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
settings = get_settings()
def get_s3_client():
return boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
config=Config(signature_version="s3v4"),
verify=False, # FirstVDS uses self-signed certificate
)
async def get_total_storage_size() -> int:
"""Returns total size of all objects in bucket in bytes"""
client = get_s3_client()
total_size = 0
paginator = client.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=settings.s3_bucket_name):
for obj in page.get("Contents", []):
total_size += obj["Size"]
return total_size
async def can_upload_file(file_size: int) -> bool:
"""Check if file can be uploaded without exceeding storage limit"""
max_bytes = settings.max_storage_gb * 1024 * 1024 * 1024
current_size = await get_total_storage_size()
return (current_size + file_size) <= max_bytes
async def upload_file(file_content: bytes, s3_key: str, content_type: str = "audio/mpeg") -> str:
"""Upload file to S3 and return the key"""
client = get_s3_client()
client.put_object(
Bucket=settings.s3_bucket_name,
Key=s3_key,
Body=file_content,
ContentType=content_type,
)
return s3_key
async def delete_file(s3_key: str) -> None:
"""Delete file from S3"""
client = get_s3_client()
client.delete_object(Bucket=settings.s3_bucket_name, Key=s3_key)
def generate_presigned_url(s3_key: str, expiration: int = 3600) -> str:
"""Generate presigned URL for file access"""
client = get_s3_client()
url = client.generate_presigned_url(
"get_object",
Params={"Bucket": settings.s3_bucket_name, "Key": s3_key},
ExpiresIn=expiration,
)
return url
def get_file_content(s3_key: str) -> bytes:
"""Get full file content from S3"""
client = get_s3_client()
response = client.get_object(Bucket=settings.s3_bucket_name, Key=s3_key)
return response["Body"].read()

View File

@@ -0,0 +1,48 @@
from typing import Dict, Set
from fastapi import WebSocket
from uuid import UUID
import json
class ConnectionManager:
def __init__(self):
# room_id -> set of (websocket, user_id)
self.active_connections: Dict[UUID, Set[tuple[WebSocket, UUID]]] = {}
async def connect(self, websocket: WebSocket, room_id: UUID, user_id: UUID):
await websocket.accept()
if room_id not in self.active_connections:
self.active_connections[room_id] = set()
self.active_connections[room_id].add((websocket, user_id))
def disconnect(self, websocket: WebSocket, room_id: UUID, user_id: UUID):
if room_id in self.active_connections:
self.active_connections[room_id].discard((websocket, user_id))
if not self.active_connections[room_id]:
del self.active_connections[room_id]
async def broadcast_to_room(self, room_id: UUID, message: dict, exclude_user: UUID = None):
if room_id not in self.active_connections:
return
message_json = json.dumps(message, default=str)
disconnected = []
for websocket, user_id in self.active_connections[room_id]:
if exclude_user and user_id == exclude_user:
continue
try:
await websocket.send_text(message_json)
except Exception:
disconnected.append((websocket, user_id))
for conn in disconnected:
self.active_connections[room_id].discard(conn)
def get_room_user_count(self, room_id: UUID) -> int:
if room_id not in self.active_connections:
return 0
return len(self.active_connections[room_id])
manager = ConnectionManager()

View File

View File

@@ -0,0 +1,35 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from ..config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError:
return None