init
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
32
backend/app/config.py
Normal file
32
backend/app/config.py
Normal 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
27
backend/app/database.py
Normal 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
31
backend/app/main.py
Normal 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"}
|
||||
6
backend/app/models/__init__.py
Normal file
6
backend/app/models/__init__.py
Normal 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"]
|
||||
20
backend/app/models/message.py
Normal file
20
backend/app/models/message.py
Normal 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")
|
||||
38
backend/app/models/room.py
Normal file
38
backend/app/models/room.py
Normal 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")
|
||||
38
backend/app/models/track.py
Normal file
38
backend/app/models/track.py
Normal 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")
|
||||
22
backend/app/models/user.py
Normal file
22
backend/app/models/user.py
Normal 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")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
62
backend/app/routers/auth.py
Normal file
62
backend/app/routers/auth.py
Normal 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
|
||||
38
backend/app/routers/messages.py
Normal file
38
backend/app/routers/messages.py
Normal 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)
|
||||
]
|
||||
248
backend/app/routers/rooms.py
Normal file
248
backend/app/routers/rooms.py
Normal 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"}
|
||||
222
backend/app/routers/tracks.py
Normal file
222
backend/app/routers/tracks.py
Normal 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}",
|
||||
}
|
||||
)
|
||||
234
backend/app/routers/websocket.py
Normal file
234
backend/app/routers/websocket.py
Normal 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(),
|
||||
})
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
19
backend/app/schemas/message.py
Normal file
19
backend/app/schemas/message.py
Normal 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
|
||||
47
backend/app/schemas/room.py
Normal file
47
backend/app/schemas/room.py
Normal 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
|
||||
25
backend/app/schemas/track.py
Normal file
25
backend/app/schemas/track.py
Normal 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
|
||||
33
backend/app/schemas/user.py
Normal file
33
backend/app/schemas/user.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
42
backend/app/services/auth.py
Normal file
42
backend/app/services/auth.py
Normal 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
|
||||
77
backend/app/services/s3.py
Normal file
77
backend/app/services/s3.py
Normal 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()
|
||||
48
backend/app/services/sync.py
Normal file
48
backend/app/services/sync.py
Normal 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()
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
35
backend/app/utils/security.py
Normal file
35
backend/app/utils/security.py
Normal 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
|
||||
Reference in New Issue
Block a user