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>
This commit is contained in:
2025-12-19 20:46:00 +03:00
parent ee8d79d155
commit 0fb16f791d
10 changed files with 398 additions and 9 deletions

View File

@@ -9,6 +9,7 @@ from .routers import auth, rooms, tracks, websocket, messages
from .database import async_session
from .models.room import Room
from .services.sync import manager
from .services.ping_task import ping_users_task
from .config import get_settings
# Setup logging
@@ -62,15 +63,22 @@ async def periodic_sync():
@asynccontextmanager
async def lifespan(app: FastAPI):
# Start background sync task
# Start background tasks
sync_task = asyncio.create_task(periodic_sync())
ping_task = asyncio.create_task(ping_users_task())
logger.info("Background tasks started: periodic_sync, ping_users_task")
yield
# Cleanup
sync_task.cancel()
ping_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
try:
await ping_task
except asyncio.CancelledError:
pass
app = FastAPI(title="EnigFM", description="Listen to music together with friends", lifespan=lifespan)

View File

@@ -106,7 +106,25 @@ async def delete_room(
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"}

View File

@@ -3,12 +3,13 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, update
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 ..models.room import Room
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_size, stream_file_chunks
@@ -184,10 +185,17 @@ async def delete_track(
if track.uploaded_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not track owner")
# Clear current_track_id from any rooms using this track
await db.execute(
update(Room)
.where(Room.current_track_id == track_id)
.values(current_track_id=None, is_playing=False)
)
# Delete from S3
await delete_file(track.s3_key)
# Delete from DB
# Delete from DB (queue entries will be cascade deleted automatically)
await db.delete(track)
return {"status": "deleted"}

View File

@@ -63,6 +63,11 @@ async def room_websocket(websocket: WebSocket, room_id: UUID):
await websocket.send_json({"type": "pong"})
continue
# Handle pong response (from server ping)
if message.get("type") == "pong":
manager.update_pong(room_id, user.id)
continue
async with async_session() as db:
if message["type"] == "player_action":
await handle_player_action(db, room_id, user, message)
@@ -72,11 +77,50 @@ async def room_websocket(websocket: WebSocket, room_id: UUID):
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)},
await handle_user_disconnect(websocket, room_id, user.id)
async def handle_user_disconnect(websocket: WebSocket, room_id: UUID, user_id: UUID):
"""Обработка отключения пользователя от комнаты"""
manager.disconnect(websocket, room_id, user_id)
# Удаляем пользователя из participants в БД
async with async_session() as db:
await db.execute(
select(RoomParticipant)
.where(RoomParticipant.room_id == room_id)
.where(RoomParticipant.user_id == user_id)
)
await db.execute(
RoomParticipant.__table__.delete().where(
RoomParticipant.room_id == room_id,
RoomParticipant.user_id == user_id
)
)
await db.commit()
# Проверяем, остались ли участники в комнате
room_user_count = manager.get_room_user_count(room_id)
# Если комната пустая - ставим трек на паузу
if room_user_count == 0:
result = await db.execute(select(Room).where(Room.id == room_id))
room = result.scalar_one_or_none()
if room and room.is_playing:
# Сохраняем текущую позицию
if room.playback_started_at:
elapsed = (datetime.utcnow() - room.playback_started_at).total_seconds() * 1000
room.playback_position = int((room.playback_position or 0) + elapsed)
room.is_playing = False
room.playback_started_at = None
await db.commit()
# Уведомляем остальных участников
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):

View File

@@ -0,0 +1,88 @@
import asyncio
import time
from uuid import UUID
from datetime import datetime
from sqlalchemy import select, delete
from app.database import async_session
from app.models.room import Room, RoomParticipant
from app.services.sync import manager
async def ping_users_task():
"""Фоновая задача для периодического пинга пользователей и отключения неактивных"""
while True:
try:
await asyncio.sleep(manager.ping_interval)
current_time = time.time()
disconnected_users = []
# Проходим по всем активным соединениям
for room_id, connections in list(manager.get_all_connections().items()):
for websocket, user_id in list(connections):
# Отправляем ping
try:
await websocket.send_json({"type": "ping"})
except Exception:
# Если не смогли отправить - помечаем на отключение
disconnected_users.append((websocket, room_id, user_id))
continue
# Проверяем время последнего pong
last_pong_time = manager.last_pong.get((room_id, user_id))
if last_pong_time is None or (current_time - last_pong_time) > manager.ping_timeout:
# Пользователь не отвечает слишком долго
disconnected_users.append((websocket, room_id, user_id))
# Отключаем неактивных пользователей
for websocket, room_id, user_id in disconnected_users:
await disconnect_inactive_user(websocket, room_id, user_id)
except Exception as e:
print(f"Error in ping_users_task: {e}")
await asyncio.sleep(5) # Подождём немного при ошибке
async def disconnect_inactive_user(websocket, room_id: UUID, user_id: UUID):
"""Отключить неактивного пользователя"""
try:
# Закрываем WebSocket соединение
await websocket.close(code=1000, reason="Ping timeout")
except Exception:
pass
# Удаляем из менеджера соединений
manager.disconnect(websocket, room_id, user_id)
# Удаляем из БД
async with async_session() as db:
await db.execute(
delete(RoomParticipant).where(
RoomParticipant.room_id == room_id,
RoomParticipant.user_id == user_id
)
)
await db.commit()
# Проверяем, остались ли участники
room_user_count = manager.get_room_user_count(room_id)
# Если комната пустая - ставим трек на паузу
if room_user_count == 0:
result = await db.execute(select(Room).where(Room.id == room_id))
room = result.scalar_one_or_none()
if room and room.is_playing:
# Сохраняем текущую позицию
if room.playback_started_at:
elapsed = (datetime.utcnow() - room.playback_started_at).total_seconds() * 1000
room.playback_position = int((room.playback_position or 0) + elapsed)
room.is_playing = False
room.playback_started_at = None
await db.commit()
# Уведомляем остальных участников
await manager.broadcast_to_room(
room_id,
{"type": "user_left", "user_id": str(user_id), "reason": "timeout"},
)

View File

@@ -2,24 +2,39 @@ from typing import Dict, Set
from fastapi import WebSocket
from uuid import UUID
import json
import time
import asyncio
class ConnectionManager:
def __init__(self):
# room_id -> set of (websocket, user_id)
self.active_connections: Dict[UUID, Set[tuple[WebSocket, UUID]]] = {}
# (room_id, user_id) -> last pong timestamp
self.last_pong: Dict[tuple[UUID, UUID], float] = {}
# Ping interval and timeout in seconds
self.ping_interval = 30 # Отправлять ping каждые 30 секунд
self.ping_timeout = 60 # Отключать если нет ответа 60 секунд
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))
# Инициализируем время последнего pong
self.last_pong[(room_id, user_id)] = time.time()
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]
# Удаляем запись о последнем pong
self.last_pong.pop((room_id, user_id), None)
def update_pong(self, room_id: UUID, user_id: UUID):
"""Обновить время последнего pong от пользователя"""
self.last_pong[(room_id, user_id)] = time.time()
async def broadcast_to_room(self, room_id: UUID, message: dict, exclude_user: UUID = None):
if room_id not in self.active_connections:
@@ -39,10 +54,35 @@ class ConnectionManager:
for conn in disconnected:
self.active_connections[room_id].discard(conn)
async def send_to_user(self, room_id: UUID, user_id: UUID, message: dict):
"""Отправить сообщение конкретному пользователю в комнате"""
if room_id not in self.active_connections:
return False
message_json = json.dumps(message, default=str)
for websocket, uid in self.active_connections[room_id]:
if uid == user_id:
try:
await websocket.send_text(message_json)
return True
except Exception:
return False
return False
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])
def get_room_users(self, room_id: UUID) -> Set[UUID]:
"""Получить список user_id в комнате"""
if room_id not in self.active_connections:
return set()
return {user_id for _, user_id in self.active_connections[room_id]}
def get_all_connections(self) -> Dict[UUID, Set[tuple[WebSocket, UUID]]]:
"""Получить все активные соединения (для фоновой задачи пинга)"""
return self.active_connections
manager = ConnectionManager()