diff --git a/.env.example b/.env.example
index 5a6fca9..7d18f8b 100644
--- a/.env.example
+++ b/.env.example
@@ -1,3 +1,8 @@
+# Database
+DB_USER=postgres
+DB_PASSWORD=postgres
+DB_NAME=enigfm
+
# JWT Secret (обязательно смените!)
SECRET_KEY=your-secret-key-change-in-production
diff --git a/backend/app/routers/tracks.py b/backend/app/routers/tracks.py
index d49586b..1187742 100644
--- a/backend/app/routers/tracks.py
+++ b/backend/app/routers/tracks.py
@@ -1,6 +1,6 @@
import uuid
from urllib.parse import quote
-from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request, Response
+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
@@ -11,7 +11,7 @@ from ..models.user import User
from ..models.track import Track
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_content
+from ..services.s3 import upload_file, delete_file, generate_presigned_url, can_upload_file, get_file_size, stream_file_chunks
from ..config import get_settings
settings = get_settings()
@@ -172,9 +172,11 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
- # Get full file content
- content = get_file_content(track.s3_key)
- file_size = len(content)
+ # Get file size from S3 (without downloading)
+ file_size = get_file_size(track.s3_key)
+
+ # Encode filename for non-ASCII characters
+ encoded_filename = quote(f"{track.title}.mp3")
# Parse Range header
range_header = request.headers.get("range")
@@ -192,11 +194,8 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
end = min(end, file_size - 1)
content_length = end - start + 1
- # Encode filename for non-ASCII characters
- encoded_filename = quote(f"{track.title}.mp3")
-
- return Response(
- content=content[start:end + 1],
+ return StreamingResponse(
+ stream_file_chunks(track.s3_key, start, end),
status_code=206,
media_type="audio/mpeg",
headers={
@@ -207,12 +206,9 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
}
)
- # Encode filename for non-ASCII characters
- encoded_filename = quote(f"{track.title}.mp3")
-
- # No range - return full file
- return Response(
- content=content,
+ # No range - stream full file
+ return StreamingResponse(
+ stream_file_chunks(track.s3_key),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",
diff --git a/backend/app/services/s3.py b/backend/app/services/s3.py
index 7b90cb3..b877171 100644
--- a/backend/app/services/s3.py
+++ b/backend/app/services/s3.py
@@ -75,3 +75,40 @@ def get_file_content(s3_key: str) -> bytes:
client = get_s3_client()
response = client.get_object(Bucket=settings.s3_bucket_name, Key=s3_key)
return response["Body"].read()
+
+
+def get_file_size(s3_key: str) -> int:
+ """Get file size from S3 without downloading"""
+ client = get_s3_client()
+ response = client.head_object(Bucket=settings.s3_bucket_name, Key=s3_key)
+ return response["ContentLength"]
+
+
+def get_file_range(s3_key: str, start: int, end: int):
+ """Get a range of bytes from S3 file"""
+ client = get_s3_client()
+ response = client.get_object(
+ Bucket=settings.s3_bucket_name,
+ Key=s3_key,
+ Range=f"bytes={start}-{end}"
+ )
+ return response["Body"].read()
+
+
+def stream_file_chunks(s3_key: str, start: int = 0, end: int = None, chunk_size: int = 64 * 1024):
+ """Stream file from S3 in chunks (default 64KB chunks)"""
+ client = get_s3_client()
+
+ if end is None:
+ range_header = f"bytes={start}-"
+ else:
+ range_header = f"bytes={start}-{end}"
+
+ response = client.get_object(
+ Bucket=settings.s3_bucket_name,
+ Key=s3_key,
+ Range=range_header
+ )
+
+ for chunk in response["Body"].iter_chunks(chunk_size=chunk_size):
+ yield chunk
diff --git a/docker-compose.yml b/docker-compose.yml
index 7f1dc6e..af66655 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,15 +4,15 @@ services:
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: enigfm
+ POSTGRES_USER: ${DB_USER}
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ POSTGRES_DB: ${DB_NAME}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "4002:5432"
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U postgres"]
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 5s
timeout: 5s
retries: 5
@@ -22,7 +22,7 @@ services:
context: ./backend
dockerfile: Dockerfile
environment:
- DATABASE_URL: postgresql://postgres:postgres@db:5432/enigfm
+ DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 99721d6..85c2c40 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,14 +1,19 @@
-
+
+
diff --git a/frontend/src/components/chat/ChatWindow.vue b/frontend/src/components/chat/ChatWindow.vue
index ae9391f..4ed240f 100644
--- a/frontend/src/components/chat/ChatWindow.vue
+++ b/frontend/src/components/chat/ChatWindow.vue
@@ -3,7 +3,7 @@
Чат
@@ -13,7 +13,7 @@
type="text"
v-model="newMessage"
placeholder="Написать сообщение..."
- :disabled="!ws.connected"
+ :disabled="!activeRoomStore.connected"
/>
@@ -27,7 +29,7 @@ defineProps({
}
})
-defineEmits(['play-track'])
+defineEmits(['play-track', 'remove-track'])
function formatDuration(ms) {
const seconds = Math.floor(ms / 1000)
@@ -95,4 +97,20 @@ function formatDuration(ms) {
color: #aaa;
font-size: 12px;
}
+
+.btn-remove {
+ background: transparent;
+ border: none;
+ color: #666;
+ cursor: pointer;
+ padding: 4px 8px;
+ font-size: 14px;
+ border-radius: 4px;
+ transition: all 0.2s;
+}
+
+.btn-remove:hover {
+ background: #ff4444;
+ color: white;
+}
diff --git a/frontend/src/composables/usePlayer.js b/frontend/src/composables/usePlayer.js
index d1003af..1dd8824 100644
--- a/frontend/src/composables/usePlayer.js
+++ b/frontend/src/composables/usePlayer.js
@@ -69,7 +69,13 @@ export function usePlayer(onTrackEnded = null) {
initAudio()
}
- if (state.track_url && state.track_url !== playerStore.currentTrackUrl) {
+ // Load track if URL changed OR if audio has no source (e.g. after returning to room)
+ const needsLoad = state.track_url && (
+ state.track_url !== playerStore.currentTrackUrl ||
+ !audio.value.src
+ )
+
+ if (needsLoad) {
loadTrack(state.track_url)
playerStore.currentTrackUrl = state.track_url
}
diff --git a/frontend/src/stores/activeRoom.js b/frontend/src/stores/activeRoom.js
new file mode 100644
index 0000000..1f46eca
--- /dev/null
+++ b/frontend/src/stores/activeRoom.js
@@ -0,0 +1,223 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import { useAuthStore } from './auth'
+import { usePlayerStore } from './player'
+import { useRoomStore } from './room'
+import api from '../composables/useApi'
+
+export const useActiveRoomStore = defineStore('activeRoom', () => {
+ const ws = ref(null)
+ const connected = ref(false)
+ const roomId = ref(null)
+ const roomName = ref(null)
+ const chatMessages = ref([])
+
+ const authStore = useAuthStore()
+ const playerStore = usePlayerStore()
+ const roomStore = useRoomStore()
+
+ // Audio element
+ let audio = null
+ let onTrackEndedCallback = null
+
+ const isInRoom = computed(() => roomId.value !== null)
+
+ function initAudio() {
+ if (audio) return
+
+ audio = new Audio()
+ audio.volume = playerStore.volume / 100
+
+ audio.addEventListener('timeupdate', () => {
+ playerStore.setPosition(Math.floor(audio.currentTime * 1000))
+ })
+
+ audio.addEventListener('loadedmetadata', () => {
+ playerStore.setDuration(Math.floor(audio.duration * 1000))
+ })
+
+ audio.addEventListener('ended', () => {
+ if (onTrackEndedCallback) {
+ onTrackEndedCallback()
+ }
+ })
+ }
+
+ function connect(id, name) {
+ if (ws.value && ws.value.readyState === WebSocket.OPEN) {
+ if (roomId.value === id) return
+ disconnect()
+ }
+
+ roomId.value = id
+ roomName.value = name
+
+ const wsUrl = import.meta.env.VITE_WS_URL || window.location.origin.replace('http', 'ws')
+ ws.value = new WebSocket(`${wsUrl}/ws/rooms/${id}?token=${authStore.token}`)
+
+ ws.value.onopen = () => {
+ connected.value = true
+ send({ type: 'sync_request' })
+ }
+
+ ws.value.onclose = () => {
+ connected.value = false
+ }
+
+ ws.value.onerror = () => {}
+
+ ws.value.onmessage = (event) => {
+ const data = JSON.parse(event.data)
+ handleMessage(data)
+ }
+
+ // Set callback for track ended
+ onTrackEndedCallback = () => {
+ sendPlayerAction('next')
+ }
+ }
+
+ function disconnect() {
+ if (ws.value) {
+ ws.value.close()
+ ws.value = null
+ }
+ connected.value = false
+ roomId.value = null
+ roomName.value = null
+ chatMessages.value = []
+ playerStore.reset()
+ if (audio) {
+ audio.pause()
+ audio.src = ''
+ }
+ }
+
+ function send(data) {
+ if (ws.value && ws.value.readyState === WebSocket.OPEN) {
+ ws.value.send(JSON.stringify(data))
+ }
+ }
+
+ function sendPlayerAction(action, position = null, trackId = null) {
+ send({
+ type: 'player_action',
+ action,
+ position,
+ track_id: trackId,
+ })
+ }
+
+ function sendChatMessage(text) {
+ send({
+ type: 'chat_message',
+ text,
+ })
+ }
+
+ function handleMessage(msg) {
+ switch (msg.type) {
+ case 'player_state':
+ case 'sync_state':
+ syncToState(msg)
+ playerStore.setPlayerState(msg)
+ break
+ case 'user_joined':
+ roomStore.addParticipant(msg.user)
+ break
+ case 'user_left':
+ roomStore.removeParticipant(msg.user_id)
+ break
+ case 'queue_updated':
+ if (roomId.value) {
+ roomStore.fetchQueue(roomId.value)
+ }
+ break
+ case 'chat_message':
+ chatMessages.value.push({
+ id: msg.id,
+ user_id: msg.user_id,
+ username: msg.username,
+ text: msg.text,
+ created_at: msg.created_at
+ })
+ break
+ }
+ }
+
+ function syncToState(state) {
+ if (!audio) {
+ initAudio()
+ }
+
+ const needsLoad = state.track_url && (
+ state.track_url !== playerStore.currentTrackUrl ||
+ !audio.src
+ )
+
+ if (needsLoad) {
+ const apiUrl = import.meta.env.VITE_API_URL || ''
+ const fullUrl = state.track_url.startsWith('/') ? `${apiUrl}${state.track_url}` : state.track_url
+ audio.src = fullUrl
+ audio.load()
+ playerStore.currentTrackUrl = state.track_url
+ }
+
+ if (state.position !== undefined) {
+ const diff = Math.abs(state.position - playerStore.position)
+ if (diff > 2000) {
+ audio.currentTime = state.position / 1000
+ }
+ }
+
+ if (state.is_playing) {
+ audio.play().catch(() => {})
+ } else {
+ audio.pause()
+ }
+ }
+
+ function play() {
+ if (audio) {
+ audio.play().catch(() => {})
+ }
+ }
+
+ function pause() {
+ if (audio) {
+ audio.pause()
+ }
+ }
+
+ function setVolume(vol) {
+ if (audio) {
+ audio.volume = vol / 100
+ }
+ playerStore.setVolume(vol)
+ }
+
+ async function leaveRoom() {
+ if (roomId.value) {
+ await api.post(`/api/rooms/${roomId.value}/leave`)
+ }
+ disconnect()
+ }
+
+ return {
+ ws,
+ connected,
+ roomId,
+ roomName,
+ chatMessages,
+ isInRoom,
+ connect,
+ disconnect,
+ send,
+ sendPlayerAction,
+ sendChatMessage,
+ leaveRoom,
+ play,
+ pause,
+ setVolume,
+ }
+})
diff --git a/frontend/src/stores/player.js b/frontend/src/stores/player.js
index d188eaa..4c5c5c8 100644
--- a/frontend/src/stores/player.js
+++ b/frontend/src/stores/player.js
@@ -47,6 +47,14 @@ export const usePlayerStore = defineStore('player', () => {
isPlaying.value = false
}
+ function reset() {
+ isPlaying.value = false
+ currentTrack.value = null
+ currentTrackUrl.value = null
+ position.value = 0
+ duration.value = 0
+ }
+
// Load saved volume
const savedVolume = localStorage.getItem('volume')
if (savedVolume) {
@@ -67,5 +75,6 @@ export const usePlayerStore = defineStore('player', () => {
setVolume,
play,
pause,
+ reset,
}
})
diff --git a/frontend/src/views/RoomView.vue b/frontend/src/views/RoomView.vue
index 0f1551e..8784d86 100644
--- a/frontend/src/views/RoomView.vue
+++ b/frontend/src/views/RoomView.vue
@@ -2,28 +2,22 @@