Backend changes: - Fix track deletion foreign key constraint (tracks.py) * Clear current_track_id from rooms before deleting track * Prevent deletion errors when track is currently playing - Implement user ping/keepalive system (sync.py, websocket.py, ping_task.py, main.py) * Track last pong timestamp for each user * Background task sends ping every 30s, disconnects users after 60s timeout * Auto-pause playback when room becomes empty * Remove disconnected users from room_participants - Enhance room deletion (rooms.py) * Broadcast room_deleted event to all connected users * Close all WebSocket connections before deletion * Cascade delete participants, queue, and messages Frontend changes: - Add ping/pong WebSocket handling (activeRoom.js) * Auto-respond to server pings * Handle room_deleted event with redirect to home - Add room deletion UI (RoomView.vue, HomeView.vue, RoomCard.vue) * Delete button visible only to room owner * Confirmation dialog with warning * Delete button on room cards (shows on hover) * Redirect to home page after deletion 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
363 lines
11 KiB
Python
363 lines
11 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 manager.broadcast_to_room(
|
||
room_id,
|
||
{"type": "room_deleted", "message": "Room has been deleted by owner"},
|
||
)
|
||
|
||
# Отключаем всех пользователей от WebSocket
|
||
if room_id in manager.active_connections:
|
||
connections = list(manager.active_connections[room_id])
|
||
for websocket, user_id in connections:
|
||
try:
|
||
await websocket.close(code=1000, reason="Room deleted")
|
||
except Exception:
|
||
pass
|
||
manager.disconnect(websocket, room_id, user_id)
|
||
|
||
# Удаляем комнату (cascade delete удалит participants, queue, messages)
|
||
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"}
|