Files
enigFM/backend/app/routers/rooms.py
mamonov.ep 0fb16f791d Add user ping system and room deletion functionality
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>
2025-12-19 20:46:00 +03:00

363 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"}