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:
@@ -1,13 +1,23 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
from .routers import auth, rooms, tracks, websocket, messages
|
||||
from .database import async_session
|
||||
from .models.room import Room
|
||||
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():
|
||||
@@ -65,6 +75,15 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
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
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
||||
@@ -19,8 +19,16 @@ router = APIRouter(prefix="/api/tracks", tags=["tracks"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TrackResponse])
|
||||
async def get_tracks(db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Track).order_by(Track.created_at.desc()))
|
||||
async def get_tracks(
|
||||
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()
|
||||
|
||||
|
||||
@@ -174,6 +182,8 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
|
||||
|
||||
# Get file size from S3 (without downloading)
|
||||
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
|
||||
encoded_filename = quote(f"{track.title}.mp3")
|
||||
|
||||
@@ -58,6 +58,11 @@ async def room_websocket(websocket: WebSocket, room_id: UUID):
|
||||
data = await websocket.receive_text()
|
||||
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:
|
||||
if message["type"] == "player_action":
|
||||
await handle_player_action(db, room_id, user, message)
|
||||
|
||||
@@ -77,11 +77,16 @@ def get_file_content(s3_key: str) -> bytes:
|
||||
return response["Body"].read()
|
||||
|
||||
|
||||
def get_file_size(s3_key: str) -> int:
|
||||
"""Get file size from S3 without downloading"""
|
||||
def get_file_size(s3_key: str) -> int | None:
|
||||
"""Get file size from S3 without downloading. Returns None if file not found."""
|
||||
client = get_s3_client()
|
||||
response = client.head_object(Bucket=settings.s3_bucket_name, Key=s3_key)
|
||||
return response["ContentLength"]
|
||||
try:
|
||||
response = client.head_object(Bucket=settings.s3_bucket_name, Key=s3_key)
|
||||
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):
|
||||
|
||||
@@ -22,6 +22,10 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
|
||||
let pendingPlay = false
|
||||
let pendingPosition = null
|
||||
|
||||
// WebSocket keepalive
|
||||
let pingInterval = null
|
||||
let reconnectTimeout = null
|
||||
|
||||
const isInRoom = computed(() => roomId.value !== null)
|
||||
|
||||
function initAudio() {
|
||||
@@ -53,10 +57,15 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
|
||||
})
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
console.log('Track ended event fired')
|
||||
if (onTrackEndedCallback) {
|
||||
onTrackEndedCallback()
|
||||
}
|
||||
})
|
||||
|
||||
audio.addEventListener('error', (e) => {
|
||||
console.error('Audio error:', e, audio.error)
|
||||
})
|
||||
}
|
||||
|
||||
function connect(id, name) {
|
||||
@@ -74,10 +83,36 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
|
||||
ws.value.onopen = () => {
|
||||
connected.value = true
|
||||
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 = () => {
|
||||
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 = () => {}
|
||||
@@ -94,13 +129,26 @@ export const useActiveRoomStore = defineStore('activeRoom', () => {
|
||||
}
|
||||
|
||||
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) {
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
}
|
||||
connected.value = false
|
||||
roomId.value = null
|
||||
roomName.value = null
|
||||
chatMessages.value = []
|
||||
playerStore.reset()
|
||||
if (audio) {
|
||||
|
||||
@@ -6,10 +6,10 @@ export const useTracksStore = defineStore('tracks', () => {
|
||||
const tracks = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchTracks() {
|
||||
async function fetchTracks(myOnly = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/api/tracks')
|
||||
const response = await api.get('/api/tracks', { params: { my: myOnly } })
|
||||
tracks.value = response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
<button class="btn-primary" @click="showUpload = true">Загрузить трек</button>
|
||||
</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-else-if="tracksStore.tracks.length === 0" class="empty">
|
||||
@@ -34,11 +49,17 @@ import Modal from '../components/common/Modal.vue'
|
||||
const tracksStore = useTracksStore()
|
||||
|
||||
const showUpload = ref(false)
|
||||
const showMyOnly = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
tracksStore.fetchTracks()
|
||||
})
|
||||
|
||||
function setFilter(myOnly) {
|
||||
showMyOnly.value = myOnly
|
||||
tracksStore.fetchTracks(myOnly)
|
||||
}
|
||||
|
||||
async function handleDelete(track) {
|
||||
if (confirm(`Удалить трек "${track.title}"?`)) {
|
||||
await tracksStore.deleteTrack(track.id)
|
||||
@@ -71,4 +92,29 @@ async function handleDelete(track) {
|
||||
padding: 40px;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user