Add track filtering, WS keepalive, and improve error handling

- Add track filtering by uploader (my tracks / all tracks) with UI tabs
- Add WebSocket ping/pong keepalive (30s interval) to prevent disconnects
- Add auto-reconnect on WebSocket close (3s delay)
- Add request logging middleware with DATABASE_URL output on startup
- Handle missing S3 files gracefully (return 404 instead of 500)
- Add debug logging for audio ended event

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-12 18:10:25 +03:00
parent 3dd10d6dab
commit fdc854256c
7 changed files with 144 additions and 11 deletions

View File

@@ -1,13 +1,23 @@
import asyncio import asyncio
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from .routers import auth, rooms, tracks, websocket, messages 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 .config import get_settings
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Log config on startup
settings = get_settings()
logger.info(f"DATABASE_URL: {settings.database_url}")
async def periodic_sync(): async def periodic_sync():
@@ -65,6 +75,15 @@ async def lifespan(app: FastAPI):
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)
@app.middleware("http")
async def log_requests(request: Request, call_next):
logger.info(f"Request: {request.method} {request.url.path}")
response = await call_next(request)
logger.info(f"Response: {request.method} {request.url.path} - {response.status_code}")
return response
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -19,8 +19,16 @@ router = APIRouter(prefix="/api/tracks", tags=["tracks"])
@router.get("", response_model=list[TrackResponse]) @router.get("", response_model=list[TrackResponse])
async def get_tracks(db: AsyncSession = Depends(get_db)): async def get_tracks(
result = await db.execute(select(Track).order_by(Track.created_at.desc())) my: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
query = select(Track)
if my:
query = query.where(Track.uploaded_by == current_user.id)
query = query.order_by(Track.created_at.desc())
result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@@ -174,6 +182,8 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
# Get file size from S3 (without downloading) # Get file size from S3 (without downloading)
file_size = get_file_size(track.s3_key) file_size = get_file_size(track.s3_key)
if file_size is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track file not found in storage")
# Encode filename for non-ASCII characters # Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3") encoded_filename = quote(f"{track.title}.mp3")

View File

@@ -58,6 +58,11 @@ async def room_websocket(websocket: WebSocket, room_id: UUID):
data = await websocket.receive_text() data = await websocket.receive_text()
message = json.loads(data) message = json.loads(data)
# Handle ping/pong for keepalive
if message.get("type") == "ping":
await websocket.send_json({"type": "pong"})
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)

View File

@@ -77,11 +77,16 @@ def get_file_content(s3_key: str) -> bytes:
return response["Body"].read() return response["Body"].read()
def get_file_size(s3_key: str) -> int: def get_file_size(s3_key: str) -> int | None:
"""Get file size from S3 without downloading""" """Get file size from S3 without downloading. Returns None if file not found."""
client = get_s3_client() client = get_s3_client()
try:
response = client.head_object(Bucket=settings.s3_bucket_name, Key=s3_key) response = client.head_object(Bucket=settings.s3_bucket_name, Key=s3_key)
return response["ContentLength"] return response["ContentLength"]
except client.exceptions.ClientError as e:
if e.response['Error']['Code'] == '404':
return None
raise
def get_file_range(s3_key: str, start: int, end: int): def get_file_range(s3_key: str, start: int, end: int):

View File

@@ -22,6 +22,10 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
let pendingPlay = false let pendingPlay = false
let pendingPosition = null let pendingPosition = null
// WebSocket keepalive
let pingInterval = null
let reconnectTimeout = null
const isInRoom = computed(() => roomId.value !== null) const isInRoom = computed(() => roomId.value !== null)
function initAudio() { function initAudio() {
@@ -53,10 +57,15 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
}) })
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
console.log('Track ended event fired')
if (onTrackEndedCallback) { if (onTrackEndedCallback) {
onTrackEndedCallback() onTrackEndedCallback()
} }
}) })
audio.addEventListener('error', (e) => {
console.error('Audio error:', e, audio.error)
})
} }
function connect(id, name) { function connect(id, name) {
@@ -74,10 +83,36 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
ws.value.onopen = () => { ws.value.onopen = () => {
connected.value = true connected.value = true
send({ type: 'sync_request' }) send({ type: 'sync_request' })
// Start ping interval (every 30 seconds)
if (pingInterval) clearInterval(pingInterval)
pingInterval = setInterval(() => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
send({ type: 'ping' })
}
}, 30000)
} }
ws.value.onclose = () => { ws.value.onclose = () => {
connected.value = false connected.value = false
if (pingInterval) {
clearInterval(pingInterval)
pingInterval = null
}
// Auto-reconnect after 3 seconds if we still have room info
if (roomId.value && !reconnectTimeout) {
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null
if (roomId.value) {
console.log('Reconnecting WebSocket...')
const savedId = roomId.value
const savedName = roomName.value
ws.value = null
connect(savedId, savedName)
}
}, 3000)
}
} }
ws.value.onerror = () => {} ws.value.onerror = () => {}
@@ -94,13 +129,26 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
} }
function disconnect() { function disconnect() {
// Clear timers
if (pingInterval) {
clearInterval(pingInterval)
pingInterval = null
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
// Clear room info first to prevent auto-reconnect
const wasInRoom = roomId.value
roomId.value = null
roomName.value = null
if (ws.value) { if (ws.value) {
ws.value.close() ws.value.close()
ws.value = null ws.value = null
} }
connected.value = false connected.value = false
roomId.value = null
roomName.value = null
chatMessages.value = [] chatMessages.value = []
playerStore.reset() playerStore.reset()
if (audio) { if (audio) {

View File

@@ -6,10 +6,10 @@ export const useTracksStore = defineStore('tracks', () => {
const tracks = ref([]) const tracks = ref([])
const loading = ref(false) const loading = ref(false)
async function fetchTracks() { async function fetchTracks(myOnly = false) {
loading.value = true loading.value = true
try { try {
const response = await api.get('/api/tracks') const response = await api.get('/api/tracks', { params: { my: myOnly } })
tracks.value = response.data tracks.value = response.data
} finally { } finally {
loading.value = false loading.value = false

View File

@@ -5,6 +5,21 @@
<button class="btn-primary" @click="showUpload = true">Загрузить трек</button> <button class="btn-primary" @click="showUpload = true">Загрузить трек</button>
</div> </div>
<div class="filter-tabs">
<button
:class="['filter-tab', { active: !showMyOnly }]"
@click="setFilter(false)"
>
Все треки
</button>
<button
:class="['filter-tab', { active: showMyOnly }]"
@click="setFilter(true)"
>
Мои треки
</button>
</div>
<div v-if="tracksStore.loading" class="loading">Загрузка...</div> <div v-if="tracksStore.loading" class="loading">Загрузка...</div>
<div v-else-if="tracksStore.tracks.length === 0" class="empty"> <div v-else-if="tracksStore.tracks.length === 0" class="empty">
@@ -34,11 +49,17 @@ import Modal from '../components/common/Modal.vue'
const tracksStore = useTracksStore() const tracksStore = useTracksStore()
const showUpload = ref(false) const showUpload = ref(false)
const showMyOnly = ref(false)
onMounted(() => { onMounted(() => {
tracksStore.fetchTracks() tracksStore.fetchTracks()
}) })
function setFilter(myOnly) {
showMyOnly.value = myOnly
tracksStore.fetchTracks(myOnly)
}
async function handleDelete(track) { async function handleDelete(track) {
if (confirm(`Удалить трек "${track.title}"?`)) { if (confirm(`Удалить трек "${track.title}"?`)) {
await tracksStore.deleteTrack(track.id) await tracksStore.deleteTrack(track.id)
@@ -71,4 +92,29 @@ async function handleDelete(track) {
padding: 40px; padding: 40px;
color: #aaa; color: #aaa;
} }
.filter-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filter-tab {
padding: 8px 16px;
background: #333;
border: none;
border-radius: 20px;
color: #aaa;
cursor: pointer;
transition: all 0.2s;
}
.filter-tab:hover {
background: #444;
}
.filter-tab.active {
background: var(--color-primary, #1db954);
color: #fff;
}
</style> </style>