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 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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user