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 .database import async_session
from .models.room import Room from .models.room import Room
from .services.sync import manager from .services.sync import manager
from .services.ping_task import ping_users_task
from .config import get_settings from .config import get_settings
# Setup logging # Setup logging
@@ -62,15 +63,22 @@ async def periodic_sync():
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Start background sync task # Start background tasks
sync_task = asyncio.create_task(periodic_sync()) 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 yield
# Cleanup # Cleanup
sync_task.cancel() sync_task.cancel()
ping_task.cancel()
try: try:
await sync_task await sync_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
try:
await ping_task
except asyncio.CancelledError:
pass
app = FastAPI(title="EnigFM", description="Listen to music together with friends", lifespan=lifespan) 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: if room.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not room owner") 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) await db.delete(room)
return {"status": "deleted"} 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 import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func, update
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from io import BytesIO from io import BytesIO
from ..database import get_db from ..database import get_db
from ..models.user import User from ..models.user import User
from ..models.track import Track from ..models.track import Track
from ..models.room import Room
from ..schemas.track import TrackResponse, TrackWithUrl from ..schemas.track import TrackResponse, TrackWithUrl
from ..services.auth import get_current_user 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 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: if track.uploaded_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not track owner") 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 # Delete from S3
await delete_file(track.s3_key) await delete_file(track.s3_key)
# Delete from DB # Delete from DB (queue entries will be cascade deleted automatically)
await db.delete(track) await db.delete(track)
return {"status": "deleted"} return {"status": "deleted"}

View File

@@ -63,6 +63,11 @@ async def room_websocket(websocket: WebSocket, room_id: UUID):
await websocket.send_json({"type": "pong"}) await websocket.send_json({"type": "pong"})
continue 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: async with async_session() as db:
if message["type"] == "player_action": if message["type"] == "player_action":
await handle_player_action(db, room_id, user, message) 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) await handle_sync_request(db, room_id, websocket)
except WebSocketDisconnect: except WebSocketDisconnect:
manager.disconnect(websocket, room_id, user.id) await handle_user_disconnect(websocket, room_id, user.id)
await manager.broadcast_to_room(
room_id,
{"type": "user_left", "user_id": str(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): 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 fastapi import WebSocket
from uuid import UUID from uuid import UUID
import json import json
import time
import asyncio
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
# room_id -> set of (websocket, user_id) # room_id -> set of (websocket, user_id)
self.active_connections: Dict[UUID, Set[tuple[WebSocket, UUID]]] = {} 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): async def connect(self, websocket: WebSocket, room_id: UUID, user_id: UUID):
await websocket.accept() await websocket.accept()
if room_id not in self.active_connections: if room_id not in self.active_connections:
self.active_connections[room_id] = set() self.active_connections[room_id] = set()
self.active_connections[room_id].add((websocket, user_id)) 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): def disconnect(self, websocket: WebSocket, room_id: UUID, user_id: UUID):
if room_id in self.active_connections: if room_id in self.active_connections:
self.active_connections[room_id].discard((websocket, user_id)) self.active_connections[room_id].discard((websocket, user_id))
if not self.active_connections[room_id]: if not self.active_connections[room_id]:
del 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): async def broadcast_to_room(self, room_id: UUID, message: dict, exclude_user: UUID = None):
if room_id not in self.active_connections: if room_id not in self.active_connections:
@@ -39,10 +54,35 @@ class ConnectionManager:
for conn in disconnected: for conn in disconnected:
self.active_connections[room_id].discard(conn) 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: def get_room_user_count(self, room_id: UUID) -> int:
if room_id not in self.active_connections: if room_id not in self.active_connections:
return 0 return 0
return len(self.active_connections[room_id]) 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() manager = ConnectionManager()

View File

@@ -38,6 +38,15 @@
</v-chip> </v-chip>
</div> </div>
</div> </div>
<v-btn
v-if="isOwner"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click.stop="$emit('delete')"
class="delete-btn"
/>
</div> </div>
<v-divider class="my-3" /> <v-divider class="my-3" />
@@ -51,12 +60,23 @@
</template> </template>
<script setup> <script setup>
defineProps({ import { computed } from 'vue'
import { useAuthStore } from '../../stores/auth'
const props = defineProps({
room: { room: {
type: Object, type: Object,
required: true required: true
} }
}) })
defineEmits(['delete'])
const authStore = useAuthStore()
const isOwner = computed(() => {
return authStore.user && props.room.owner_id === authStore.user.id
})
</script> </script>
<style scoped> <style scoped>
@@ -132,4 +152,13 @@ defineProps({
opacity: 0.7; opacity: 0.7;
} }
} }
.delete-btn {
opacity: 0;
transition: opacity 0.2s;
}
.room-card:hover .delete-btn {
opacity: 1;
}
</style> </style>

View File

@@ -181,6 +181,17 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
function handleMessage(msg) { function handleMessage(msg) {
switch (msg.type) { switch (msg.type) {
case 'ping':
// Отвечаем на серверный ping
send({ type: 'pong' })
break
case 'pong':
// Ответ на наш клиентский ping (игнорируем)
break
case 'room_deleted':
// Комната удалена владельцем
handleRoomDeleted(msg.message)
break
case 'player_state': case 'player_state':
case 'sync_state': case 'sync_state':
syncToState(msg) syncToState(msg)
@@ -209,6 +220,16 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
} }
} }
function handleRoomDeleted(message) {
// Отключаемся от комнаты
disconnect()
// Перенаправляем на главную страницу
if (typeof window !== 'undefined') {
window.location.href = '/'
}
}
function syncToState(state) { function syncToState(state) {
if (!audio) { if (!audio) {
initAudio() initAudio()

View File

@@ -43,6 +43,7 @@
:key="room.id" :key="room.id"
:room="room" :room="room"
@click="goToRoom(room.id)" @click="goToRoom(room.id)"
@delete="confirmDeleteRoom(room)"
/> />
</div> </div>
@@ -85,6 +86,38 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="500">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon class="mr-2">mdi-alert</v-icon>
Удалить комнату?
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>Вы уверены, что хотите удалить комнату "{{ roomToDelete?.name }}"?</p>
<p class="mt-2 text-medium-emphasis">
Это действие нельзя отменить. Все участники будут отключены, а очередь и сообщения будут удалены.
</p>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">
Отмена
</v-btn>
<v-btn
color="error"
variant="flat"
@click="deleteRoom"
:loading="deleting"
>
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div> </div>
</template> </template>
@@ -103,6 +136,9 @@ const loading = ref(true)
const showCreateModal = ref(false) const showCreateModal = ref(false)
const newRoomName = ref('') const newRoomName = ref('')
const creating = ref(false) const creating = ref(false)
const showDeleteDialog = ref(false)
const roomToDelete = ref(null)
const deleting = ref(false)
onMounted(async () => { onMounted(async () => {
await roomStore.fetchRooms() await roomStore.fetchRooms()
@@ -128,6 +164,27 @@ function goToRoom(roomId) {
router.push('/login') router.push('/login')
} }
} }
function confirmDeleteRoom(room) {
roomToDelete.value = room
showDeleteDialog.value = true
}
async function deleteRoom() {
if (!roomToDelete.value) return
try {
deleting.value = true
await roomStore.deleteRoom(roomToDelete.value.id)
showDeleteDialog.value = false
roomToDelete.value = null
} catch (e) {
console.error('Failed to delete room:', e)
alert('Ошибка при удалении комнаты')
} finally {
deleting.value = false
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -5,6 +5,16 @@
<h1 class="page-title">{{ room.name }}</h1> <h1 class="page-title">{{ room.name }}</h1>
<p class="page-subtitle">Комната для совместного прослушивания музыки</p> <p class="page-subtitle">Комната для совместного прослушивания музыки</p>
</div> </div>
<div class="room-actions" v-if="isOwner">
<v-btn
color="error"
variant="outlined"
prepend-icon="mdi-delete"
@click="showDeleteDialog = true"
>
Удалить комнату
</v-btn>
</div>
</div> </div>
<v-row class="room-layout"> <v-row class="room-layout">
@@ -201,6 +211,38 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="500">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon class="mr-2">mdi-alert</v-icon>
Удалить комнату?
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>Вы уверены, что хотите удалить комнату "{{ room.name }}"?</p>
<p class="mt-2 text-medium-emphasis">
Это действие нельзя отменить. Все участники будут отключены, а очередь и сообщения будут удалены.
</p>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">
Отмена
</v-btn>
<v-btn
color="error"
variant="flat"
@click="handleDeleteRoom"
:loading="deletingRoom"
>
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div> </div>
<div v-else class="loading-container"> <div v-else class="loading-container">
<v-progress-circular <v-progress-circular
@@ -213,7 +255,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '../stores/room' import { useRoomStore } from '../stores/room'
import { useTracksStore } from '../stores/tracks' import { useTracksStore } from '../stores/tracks'
import { useActiveRoomStore } from '../stores/activeRoom' import { useActiveRoomStore } from '../stores/activeRoom'
@@ -225,6 +267,7 @@ import TrackList from '../components/tracks/TrackList.vue'
import Modal from '../components/common/Modal.vue' import Modal from '../components/common/Modal.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const roomStore = useRoomStore() const roomStore = useRoomStore()
const tracksStore = useTracksStore() const tracksStore = useTracksStore()
const activeRoomStore = useActiveRoomStore() const activeRoomStore = useActiveRoomStore()
@@ -236,6 +279,8 @@ const showAddTrack = ref(false)
const addTrackError = ref('') const addTrackError = ref('')
const addTrackSuccess = ref('') const addTrackSuccess = ref('')
const selectedTracks = ref([]) const selectedTracks = ref([])
const showDeleteDialog = ref(false)
const deletingRoom = ref(false)
// Filters // Filters
const searchTitle = ref('') const searchTitle = ref('')
@@ -243,6 +288,10 @@ const searchArtist = ref('')
const filterMyTracks = ref(false) const filterMyTracks = ref(false)
const filterNotInQueue = ref(false) const filterNotInQueue = ref(false)
const isOwner = computed(() => {
return room.value && authStore.user && room.value.owner.id === authStore.user.id
})
const queueTrackIds = computed(() => { const queueTrackIds = computed(() => {
return roomStore.queue.map(item => item.track.id) return roomStore.queue.map(item => item.track.id)
}) })
@@ -354,6 +403,25 @@ async function addSelectedTracks() {
async function removeFromQueue(track) { async function removeFromQueue(track) {
await roomStore.removeFromQueue(roomId, track.id) await roomStore.removeFromQueue(roomId, track.id)
} }
async function handleDeleteRoom() {
try {
deletingRoom.value = true
await roomStore.deleteRoom(roomId)
// Отключаемся от WebSocket
activeRoomStore.disconnect()
// Перенаправляем на главную страницу
router.push('/')
} catch (e) {
console.error('Failed to delete room:', e)
alert('Ошибка при удалении комнаты')
} finally {
deletingRoom.value = false
showDeleteDialog.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@@ -362,7 +430,15 @@ async function removeFromQueue(track) {
} }
.room-header { .room-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px; margin-bottom: 32px;
gap: 24px;
}
.room-actions {
flex-shrink: 0;
} }
.page-title { .page-title {