Major changes: - Full UI redesign with Vuetify 3 (dark theme, modern components) - Sidebar navigation with gradient logo - Redesigned player controls with Material Design icons - New room cards, track lists, and filter UI with chips - Modern auth pages with centered cards Configuration improvements: - Centralized all settings in root .env file - Removed redundant backend/.env and frontend/.env files - Increased file upload limit to 100MB (nginx + backend) - Added build args for Vite environment variables - Frontend now uses relative paths (better for domain deployment) UI Components updated: - App.vue: v-navigation-drawer with sidebar - MiniPlayer: v-footer with modern controls - Queue: v-list with styled items - RoomView: improved filters with clickable chips - All views: Vuetify cards, buttons, text fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
345 lines
10 KiB
Python
345 lines
10 KiB
Python
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, QueueAddMultiple, QueueItemResponse
|
|
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[QueueItemResponse])
|
|
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 [
|
|
QueueItemResponse(
|
|
track=TrackResponse.model_validate(item.track),
|
|
added_by=item.added_by
|
|
)
|
|
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),
|
|
):
|
|
# Check if track already in queue
|
|
result = await db.execute(
|
|
select(RoomQueue).where(
|
|
RoomQueue.room_id == room_id,
|
|
RoomQueue.track_id == data.track_id,
|
|
)
|
|
)
|
|
existing_item = result.scalar_one_or_none()
|
|
|
|
if existing_item:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Track already in queue",
|
|
)
|
|
|
|
# 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.post("/{room_id}/queue/bulk")
|
|
async def add_multiple_to_queue(
|
|
room_id: UUID,
|
|
data: QueueAddMultiple,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Add multiple tracks to queue at once, skipping duplicates."""
|
|
if not data.track_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="No tracks provided",
|
|
)
|
|
|
|
# Get existing tracks in queue
|
|
result = await db.execute(
|
|
select(RoomQueue.track_id).where(RoomQueue.room_id == room_id)
|
|
)
|
|
existing_track_ids = set(result.scalars().all())
|
|
|
|
# Filter out duplicates
|
|
new_track_ids = [tid for tid in data.track_ids if tid not in existing_track_ids]
|
|
|
|
if not new_track_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="All tracks already in queue",
|
|
)
|
|
|
|
# Get max position
|
|
result = await db.execute(
|
|
select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id)
|
|
)
|
|
max_pos = result.scalar() or 0
|
|
|
|
# Add all new tracks
|
|
added_count = 0
|
|
for i, track_id in enumerate(new_track_ids):
|
|
queue_item = RoomQueue(
|
|
room_id=room_id,
|
|
track_id=track_id,
|
|
position=max_pos + i + 1,
|
|
added_by=current_user.id,
|
|
)
|
|
db.add(queue_item)
|
|
added_count += 1
|
|
|
|
await db.flush()
|
|
|
|
# Notify others
|
|
await manager.broadcast_to_room(
|
|
room_id,
|
|
{"type": "queue_updated"},
|
|
)
|
|
|
|
skipped_count = len(data.track_ids) - added_count
|
|
return {
|
|
"status": "added",
|
|
"added": added_count,
|
|
"skipped": skipped_count,
|
|
}
|
|
|
|
|
|
@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 not queue_item:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Track not in queue"
|
|
)
|
|
|
|
# Check if user added this track to queue
|
|
if queue_item.added_by != current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Can only remove tracks you added"
|
|
)
|
|
|
|
await db.delete(queue_item)
|
|
|
|
# Notify others
|
|
await manager.broadcast_to_room(
|
|
room_id,
|
|
{"type": "queue_updated"},
|
|
)
|
|
|
|
return {"status": "removed"}
|