init
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# JWT Secret (обязательно смените!)
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
|
||||||
|
# S3 (FirstVDS)
|
||||||
|
S3_ENDPOINT_URL=https://s3.firstvds.ru
|
||||||
|
S3_ACCESS_KEY=your-access-key
|
||||||
|
S3_SECRET_KEY=your-secret-key
|
||||||
|
S3_BUCKET_NAME=enigfm
|
||||||
|
S3_REGION=ru-1
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
80
Makefile
Normal file
80
Makefile
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
.PHONY: help dev dev-backend dev-frontend install install-backend install-frontend build up down logs migrate
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "EnigFM - Команды:"
|
||||||
|
@echo ""
|
||||||
|
@echo " make install - Установить зависимости (backend + frontend)"
|
||||||
|
@echo " make dev - Запустить dev режим (backend + frontend)"
|
||||||
|
@echo " make dev-backend - Запустить только backend"
|
||||||
|
@echo " make dev-frontend - Запустить только frontend"
|
||||||
|
@echo ""
|
||||||
|
@echo " make build - Собрать Docker образы"
|
||||||
|
@echo " make up - Запустить через Docker"
|
||||||
|
@echo " make down - Остановить Docker"
|
||||||
|
@echo " make logs - Показать логи Docker"
|
||||||
|
@echo ""
|
||||||
|
@echo " make migrate - Создать миграцию БД"
|
||||||
|
@echo " make migrate-up - Применить миграции"
|
||||||
|
@echo " make migrate-down - Откатить миграцию"
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
install: install-backend install-frontend
|
||||||
|
|
||||||
|
install-backend:
|
||||||
|
cd backend && pip install -r requirements.txt
|
||||||
|
|
||||||
|
install-frontend:
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
# Разработка
|
||||||
|
dev:
|
||||||
|
@echo "Запуск backend на :4001 и frontend на :4000"
|
||||||
|
@make -j2 dev-backend dev-frontend
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && uvicorn app.main:app --reload --port 4001
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
build:
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
up:
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
rebuild-clean:
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
logs-backend:
|
||||||
|
docker-compose logs -f backend
|
||||||
|
|
||||||
|
logs-frontend:
|
||||||
|
docker-compose logs -f frontend
|
||||||
|
|
||||||
|
# Миграции
|
||||||
|
migrate:
|
||||||
|
cd backend && alembic revision --autogenerate -m "$(msg)"
|
||||||
|
|
||||||
|
migrate-up:
|
||||||
|
cd backend && alembic upgrade head
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
cd backend && alembic downgrade -1
|
||||||
|
|
||||||
|
# БД
|
||||||
|
db-shell:
|
||||||
|
docker-compose exec db psql -U postgres -d enigfm
|
||||||
291
TECHNICAL_SPEC.md
Normal file
291
TECHNICAL_SPEC.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Техническое задание: Совместное прослушивание музыки
|
||||||
|
|
||||||
|
## 1. Описание продукта
|
||||||
|
|
||||||
|
Веб-приложение для синхронного прослушивания музыки с друзьями в реальном времени. Пользователи создают комнаты, приглашают друзей по ссылке и слушают музыку одновременно.
|
||||||
|
|
||||||
|
## 2. Основные функции
|
||||||
|
|
||||||
|
### 2.1 Комнаты
|
||||||
|
- Создание комнаты (генерация уникального ID/ссылки)
|
||||||
|
- Публичные комнаты с возможностью поиска/списка
|
||||||
|
- Присоединение по ссылке или из списка комнат
|
||||||
|
- Отображение списка участников
|
||||||
|
|
||||||
|
### 2.2 Музыкальный плеер
|
||||||
|
- Воспроизведение MP3 из S3-хранилища
|
||||||
|
- Синхронизация playback между всеми участниками
|
||||||
|
- Управление доступно всем участникам: play/pause, перемотка, следующий/предыдущий трек
|
||||||
|
- Громкость (локальная, у каждого своя)
|
||||||
|
- Очередь воспроизведения (playlist)
|
||||||
|
- Отображение текущего трека, прогресса
|
||||||
|
|
||||||
|
### 2.3 Чат
|
||||||
|
- Текстовый чат внутри комнаты
|
||||||
|
- Сообщения видны всем участникам в реальном времени
|
||||||
|
|
||||||
|
### 2.4 Управление треками
|
||||||
|
- Загрузка MP3 файлов в S3
|
||||||
|
- Общая библиотека треков (доступна всем пользователям во всех комнатах)
|
||||||
|
- Добавление треков в очередь
|
||||||
|
- Базовые метаданные: название, исполнитель
|
||||||
|
|
||||||
|
### 2.5 Пользователи
|
||||||
|
- Обязательная регистрация/авторизация
|
||||||
|
- Профиль пользователя
|
||||||
|
|
||||||
|
## 3. Технические требования
|
||||||
|
|
||||||
|
### 3.1 Стек технологий
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Vue 3 (Composition API)
|
||||||
|
- Pinia (state management)
|
||||||
|
- Vue Router
|
||||||
|
- WebSocket клиент для real-time
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Python
|
||||||
|
- FastAPI (REST API + WebSocket)
|
||||||
|
- SQLAlchemy (ORM)
|
||||||
|
- Alembic (миграции)
|
||||||
|
|
||||||
|
**База данных:**
|
||||||
|
- PostgreSQL
|
||||||
|
|
||||||
|
**Хранилище файлов:**
|
||||||
|
- S3 (FirstVDS)
|
||||||
|
|
||||||
|
### 3.2 Синхронизация
|
||||||
|
- WebSocket для real-time коммуникации
|
||||||
|
- Компенсация сетевой задержки
|
||||||
|
- Периодическая синхронизация позиции трека
|
||||||
|
|
||||||
|
### 3.3 Хранилище
|
||||||
|
- S3 (FirstVDS) для MP3 файлов
|
||||||
|
- Presigned URLs для безопасного доступа к файлам
|
||||||
|
- Ограничение размера файла: 10MB
|
||||||
|
- Лимит общего объёма хранилища: 90GB (проверка перед загрузкой)
|
||||||
|
|
||||||
|
### 3.4 Масштабируемость
|
||||||
|
- Лимит участников на комнату (например, 50)
|
||||||
|
- Автоудаление неактивных комнат
|
||||||
|
|
||||||
|
## 4. Схема базы данных
|
||||||
|
|
||||||
|
### users
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | UUID | Первичный ключ |
|
||||||
|
| username | VARCHAR(50) | Уникальное имя пользователя |
|
||||||
|
| email | VARCHAR(255) | Email (уникальный) |
|
||||||
|
| password_hash | VARCHAR(255) | Хэш пароля |
|
||||||
|
| created_at | TIMESTAMP | Дата регистрации |
|
||||||
|
|
||||||
|
### rooms
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | UUID | Первичный ключ |
|
||||||
|
| name | VARCHAR(100) | Название комнаты |
|
||||||
|
| owner_id | UUID (FK) | Создатель комнаты |
|
||||||
|
| current_track_id | UUID (FK) | Текущий трек |
|
||||||
|
| playback_position | INTEGER | Позиция воспроизведения (мс) |
|
||||||
|
| is_playing | BOOLEAN | Играет ли сейчас |
|
||||||
|
| created_at | TIMESTAMP | Дата создания |
|
||||||
|
|
||||||
|
### tracks
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | UUID | Первичный ключ |
|
||||||
|
| title | VARCHAR(255) | Название трека |
|
||||||
|
| artist | VARCHAR(255) | Исполнитель |
|
||||||
|
| duration | INTEGER | Длительность (мс) |
|
||||||
|
| s3_key | VARCHAR(500) | Путь к файлу в S3 |
|
||||||
|
| uploaded_by | UUID (FK) | Кто загрузил |
|
||||||
|
| created_at | TIMESTAMP | Дата загрузки |
|
||||||
|
|
||||||
|
### room_queue
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | UUID | Первичный ключ |
|
||||||
|
| room_id | UUID (FK) | Комната |
|
||||||
|
| track_id | UUID (FK) | Трек |
|
||||||
|
| position | INTEGER | Позиция в очереди |
|
||||||
|
| added_by | UUID (FK) | Кто добавил |
|
||||||
|
|
||||||
|
### room_participants
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| room_id | UUID (FK) | Комната |
|
||||||
|
| user_id | UUID (FK) | Пользователь |
|
||||||
|
| joined_at | TIMESTAMP | Время входа |
|
||||||
|
|
||||||
|
### messages
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| id | UUID | Первичный ключ |
|
||||||
|
| room_id | UUID (FK) | Комната |
|
||||||
|
| user_id | UUID (FK) | Автор |
|
||||||
|
| text | TEXT | Текст сообщения |
|
||||||
|
| created_at | TIMESTAMP | Время отправки |
|
||||||
|
|
||||||
|
## 5. API Endpoints
|
||||||
|
|
||||||
|
### Аутентификация
|
||||||
|
- `POST /api/auth/register` — регистрация
|
||||||
|
- `POST /api/auth/login` — вход
|
||||||
|
- `POST /api/auth/logout` — выход
|
||||||
|
- `GET /api/auth/me` — текущий пользователь
|
||||||
|
|
||||||
|
### Комнаты
|
||||||
|
- `GET /api/rooms` — список публичных комнат
|
||||||
|
- `POST /api/rooms` — создать комнату
|
||||||
|
- `GET /api/rooms/{id}` — информация о комнате
|
||||||
|
- `DELETE /api/rooms/{id}` — удалить комнату (только владелец)
|
||||||
|
- `POST /api/rooms/{id}/join` — присоединиться
|
||||||
|
- `POST /api/rooms/{id}/leave` — покинуть
|
||||||
|
|
||||||
|
### Плеер (через REST + WebSocket)
|
||||||
|
- `POST /api/rooms/{id}/play` — воспроизвести
|
||||||
|
- `POST /api/rooms/{id}/pause` — пауза
|
||||||
|
- `POST /api/rooms/{id}/seek` — перемотка
|
||||||
|
- `POST /api/rooms/{id}/next` — следующий трек
|
||||||
|
- `POST /api/rooms/{id}/prev` — предыдущий трек
|
||||||
|
|
||||||
|
### Очередь
|
||||||
|
- `GET /api/rooms/{id}/queue` — очередь треков
|
||||||
|
- `POST /api/rooms/{id}/queue` — добавить трек в очередь
|
||||||
|
- `DELETE /api/rooms/{id}/queue/{track_id}` — убрать из очереди
|
||||||
|
|
||||||
|
### Треки
|
||||||
|
- `GET /api/tracks` — библиотека треков
|
||||||
|
- `POST /api/tracks/upload` — загрузить трек
|
||||||
|
- `DELETE /api/tracks/{id}` — удалить трек
|
||||||
|
|
||||||
|
### Чат
|
||||||
|
- `GET /api/rooms/{id}/messages` — история сообщений
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
- `WS /ws/rooms/{id}` — real-time события комнаты (синхронизация плеера, чат, участники)
|
||||||
|
|
||||||
|
## 6. Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
enigfm/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── main.py # Точка входа FastAPI
|
||||||
|
│ │ ├── config.py # Конфигурация (env переменные)
|
||||||
|
│ │ ├── database.py # Подключение к БД
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── models/ # SQLAlchemy модели
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── user.py
|
||||||
|
│ │ │ ├── room.py
|
||||||
|
│ │ │ ├── track.py
|
||||||
|
│ │ │ └── message.py
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── schemas/ # Pydantic схемы
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── user.py
|
||||||
|
│ │ │ ├── room.py
|
||||||
|
│ │ │ ├── track.py
|
||||||
|
│ │ │ └── message.py
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── routers/ # API роуты
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── auth.py
|
||||||
|
│ │ │ ├── rooms.py
|
||||||
|
│ │ │ ├── tracks.py
|
||||||
|
│ │ │ └── websocket.py
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── services/ # Бизнес-логика
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── auth.py
|
||||||
|
│ │ │ ├── room.py
|
||||||
|
│ │ │ ├── track.py
|
||||||
|
│ │ │ ├── s3.py # Работа с S3
|
||||||
|
│ │ │ └── sync.py # Синхронизация плеера
|
||||||
|
│ │ │
|
||||||
|
│ │ └── utils/ # Утилиты
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── security.py # JWT, хэширование
|
||||||
|
│ │
|
||||||
|
│ ├── alembic/ # Миграции БД
|
||||||
|
│ │ ├── versions/
|
||||||
|
│ │ └── env.py
|
||||||
|
│ │
|
||||||
|
│ ├── tests/
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── alembic.ini
|
||||||
|
│ └── .env.example
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.js # Точка входа
|
||||||
|
│ │ ├── App.vue
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── components/ # Vue компоненты
|
||||||
|
│ │ │ ├── player/
|
||||||
|
│ │ │ │ ├── AudioPlayer.vue
|
||||||
|
│ │ │ │ ├── PlayerControls.vue
|
||||||
|
│ │ │ │ ├── ProgressBar.vue
|
||||||
|
│ │ │ │ └── VolumeControl.vue
|
||||||
|
│ │ │ ├── room/
|
||||||
|
│ │ │ │ ├── RoomCard.vue
|
||||||
|
│ │ │ │ ├── RoomList.vue
|
||||||
|
│ │ │ │ ├── ParticipantsList.vue
|
||||||
|
│ │ │ │ └── Queue.vue
|
||||||
|
│ │ │ ├── chat/
|
||||||
|
│ │ │ │ ├── ChatWindow.vue
|
||||||
|
│ │ │ │ └── ChatMessage.vue
|
||||||
|
│ │ │ ├── tracks/
|
||||||
|
│ │ │ │ ├── TrackList.vue
|
||||||
|
│ │ │ │ ├── TrackItem.vue
|
||||||
|
│ │ │ │ └── UploadTrack.vue
|
||||||
|
│ │ │ └── common/
|
||||||
|
│ │ │ ├── Header.vue
|
||||||
|
│ │ │ └── Modal.vue
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── views/ # Страницы
|
||||||
|
│ │ │ ├── HomeView.vue # Список комнат
|
||||||
|
│ │ │ ├── RoomView.vue # Страница комнаты
|
||||||
|
│ │ │ ├── LoginView.vue
|
||||||
|
│ │ │ ├── RegisterView.vue
|
||||||
|
│ │ │ └── TracksView.vue # Библиотека треков
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── stores/ # Pinia stores
|
||||||
|
│ │ │ ├── auth.js
|
||||||
|
│ │ │ ├── room.js
|
||||||
|
│ │ │ ├── player.js
|
||||||
|
│ │ │ └── tracks.js
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── composables/ # Vue composables
|
||||||
|
│ │ │ ├── useWebSocket.js
|
||||||
|
│ │ │ ├── usePlayer.js
|
||||||
|
│ │ │ └── useApi.js
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── router/
|
||||||
|
│ │ │ └── index.js
|
||||||
|
│ │ │
|
||||||
|
│ │ └── assets/
|
||||||
|
│ │ └── styles/
|
||||||
|
│ │
|
||||||
|
│ ├── public/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ └── .env.example
|
||||||
|
│
|
||||||
|
├── docker-compose.yml # PostgreSQL, Backend, Frontend
|
||||||
|
├── .gitignore
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Принятые решения
|
||||||
|
|
||||||
|
- **Аутентификация** — обязательная регистрация
|
||||||
|
- **Права управления** — все участники могут управлять плеером
|
||||||
|
- **Чат** — текстовый чат в каждой комнате
|
||||||
|
- **Библиотека музыки** — общая для всех пользователей
|
||||||
|
- **Приватность** — все комнаты публичные
|
||||||
17
backend/.env.example
Normal file
17
backend/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:4002/enigfm
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
|
||||||
|
# S3 (FirstVDS)
|
||||||
|
S3_ENDPOINT_URL=https://s3.firstvds.ru
|
||||||
|
S3_ACCESS_KEY=your-access-key
|
||||||
|
S3_SECRET_KEY=your-secret-key
|
||||||
|
S3_BUCKET_NAME=enigfm
|
||||||
|
S3_REGION=ru-1
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
MAX_FILE_SIZE_MB=10
|
||||||
|
MAX_STORAGE_GB=90
|
||||||
|
MAX_ROOM_PARTICIPANTS=50
|
||||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Run migrations and start server
|
||||||
|
CMD alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
41
backend/alembic.ini
Normal file
41
backend/alembic.ini
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
sqlalchemy.url = postgresql://postgres:postgres@localhost:4002/enigfm
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
68
backend/alembic/env.py
Normal file
68
backend/alembic/env.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
from alembic import context
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import User, Room, RoomParticipant, Track, RoomQueue, Message
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
configuration = config.get_section(config.config_ini_section)
|
||||||
|
# Use DATABASE_URL from environment if available
|
||||||
|
db_url = os.environ.get("DATABASE_URL", configuration["sqlalchemy.url"])
|
||||||
|
configuration["sqlalchemy.url"] = db_url.replace(
|
||||||
|
"postgresql://", "postgresql+asyncpg://"
|
||||||
|
)
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
configuration,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
103
backend/alembic/versions/001_initial.py
Normal file
103
backend/alembic/versions/001_initial.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-01-01
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = '001'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Users table
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('username', sa.String(50), nullable=False),
|
||||||
|
sa.Column('email', sa.String(255), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('email'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tracks table
|
||||||
|
op.create_table('tracks',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('title', sa.String(255), nullable=False),
|
||||||
|
sa.Column('artist', sa.String(255), nullable=False),
|
||||||
|
sa.Column('duration', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('s3_key', sa.String(500), nullable=False),
|
||||||
|
sa.Column('file_size', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('uploaded_by', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rooms table
|
||||||
|
op.create_table('rooms',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('name', sa.String(100), nullable=False),
|
||||||
|
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('current_track_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column('playback_position', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('is_playing', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['current_track_id'], ['tracks.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Room participants table
|
||||||
|
op.create_table('room_participants',
|
||||||
|
sa.Column('room_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('joined_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('room_id', 'user_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Room queue table
|
||||||
|
op.create_table('room_queue',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('room_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('track_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('position', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('added_by', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['added_by'], ['users.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Messages table
|
||||||
|
op.create_table('messages',
|
||||||
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('room_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column('text', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('messages')
|
||||||
|
op.drop_table('room_queue')
|
||||||
|
op.drop_table('room_participants')
|
||||||
|
op.drop_table('rooms')
|
||||||
|
op.drop_table('tracks')
|
||||||
|
op.drop_table('users')
|
||||||
24
backend/alembic/versions/002_add_playback_started_at.py
Normal file
24
backend/alembic/versions/002_add_playback_started_at.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add playback_started_at to rooms
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2024-01-02
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '002'
|
||||||
|
down_revision: Union[str, None] = '001'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('rooms', sa.Column('playback_started_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('rooms', 'playback_started_at')
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
32
backend/app/config.py
Normal file
32
backend/app/config.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
# Database
|
||||||
|
database_url: str = "postgresql://postgres:postgres@localhost:5432/enigfm"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
secret_key: str = "your-secret-key-change-in-production"
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
# S3 (FirstVDS)
|
||||||
|
s3_endpoint_url: str = ""
|
||||||
|
s3_access_key: str = ""
|
||||||
|
s3_secret_key: str = ""
|
||||||
|
s3_bucket_name: str = "enigfm"
|
||||||
|
s3_region: str = "ru-1"
|
||||||
|
|
||||||
|
# Limits
|
||||||
|
max_file_size_mb: int = 10
|
||||||
|
max_storage_gb: int = 90
|
||||||
|
max_room_participants: int = 50
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
27
backend/app/database.py
Normal file
27
backend/app/database.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from .config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Convert postgresql:// to postgresql+asyncpg://
|
||||||
|
database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://")
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
31
backend/app/main.py
Normal file
31
backend/app/main.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from .routers import auth, rooms, tracks, websocket, messages
|
||||||
|
|
||||||
|
app = FastAPI(title="EnigFM", description="Listen to music together with friends")
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routers
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(rooms.router)
|
||||||
|
app.include_router(tracks.router)
|
||||||
|
app.include_router(messages.router)
|
||||||
|
app.include_router(websocket.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "EnigFM API", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
6
backend/app/models/__init__.py
Normal file
6
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from .user import User
|
||||||
|
from .room import Room, RoomParticipant
|
||||||
|
from .track import Track, RoomQueue
|
||||||
|
from .message import Message
|
||||||
|
|
||||||
|
__all__ = ["User", "Room", "RoomParticipant", "Track", "RoomQueue", "Message"]
|
||||||
20
backend/app/models/message.py
Normal file
20
backend/app/models/message.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Text, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Base):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rooms.id"), nullable=False)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
room = relationship("Room", back_populates="messages")
|
||||||
|
user = relationship("User", back_populates="messages")
|
||||||
38
backend/app/models/room.py
Normal file
38
backend/app/models/room.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Room(Base):
|
||||||
|
__tablename__ = "rooms"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
current_track_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tracks.id"), nullable=True)
|
||||||
|
playback_position: Mapped[int] = mapped_column(Integer, default=0) # milliseconds
|
||||||
|
playback_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # when playback started
|
||||||
|
is_playing: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
owner = relationship("User", back_populates="owned_rooms")
|
||||||
|
current_track = relationship("Track", foreign_keys=[current_track_id])
|
||||||
|
participants = relationship("RoomParticipant", back_populates="room", cascade="all, delete-orphan")
|
||||||
|
queue = relationship("RoomQueue", back_populates="room", cascade="all, delete-orphan", order_by="RoomQueue.position")
|
||||||
|
messages = relationship("Message", back_populates="room", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class RoomParticipant(Base):
|
||||||
|
__tablename__ = "room_participants"
|
||||||
|
|
||||||
|
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rooms.id"), primary_key=True)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True)
|
||||||
|
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
room = relationship("Room", back_populates="participants")
|
||||||
|
user = relationship("User", back_populates="room_participations")
|
||||||
38
backend/app/models/track.py
Normal file
38
backend/app/models/track.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Track(Base):
|
||||||
|
__tablename__ = "tracks"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
artist: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
duration: Mapped[int] = mapped_column(Integer, nullable=False) # milliseconds
|
||||||
|
s3_key: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
file_size: Mapped[int] = mapped_column(Integer, nullable=False) # bytes
|
||||||
|
uploaded_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
uploader = relationship("User", back_populates="uploaded_tracks")
|
||||||
|
queue_entries = relationship("RoomQueue", back_populates="track", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class RoomQueue(Base):
|
||||||
|
__tablename__ = "room_queue"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
room_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rooms.id"), nullable=False)
|
||||||
|
track_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tracks.id"), nullable=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
added_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
room = relationship("Room", back_populates="queue")
|
||||||
|
track = relationship("Track", back_populates="queue_entries")
|
||||||
|
added_by_user = relationship("User")
|
||||||
22
backend/app/models/user.py
Normal file
22
backend/app/models/user.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
owned_rooms = relationship("Room", back_populates="owner", cascade="all, delete-orphan")
|
||||||
|
uploaded_tracks = relationship("Track", back_populates="uploader")
|
||||||
|
messages = relationship("Message", back_populates="user")
|
||||||
|
room_participations = relationship("RoomParticipant", back_populates="user", cascade="all, delete-orphan")
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
62
backend/app/routers/auth.py
Normal file
62
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models.user import User
|
||||||
|
from ..schemas.user import UserCreate, UserLogin, UserResponse, Token
|
||||||
|
from ..utils.security import get_password_hash, verify_password, create_access_token
|
||||||
|
from ..services.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=Token)
|
||||||
|
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
# Check if email exists
|
||||||
|
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if username exists
|
||||||
|
result = await db.execute(select(User).where(User.username == user_data.username))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username already taken",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = User(
|
||||||
|
username=user_data.username,
|
||||||
|
email=user_data.email,
|
||||||
|
password_hash=get_password_hash(user_data.password),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create token
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
return Token(access_token=access_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user or not verify_password(user_data.password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or password",
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
return Token(access_token=access_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_me(current_user: User = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
38
backend/app/routers/messages.py
Normal file
38
backend/app/routers/messages.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models.message import Message
|
||||||
|
from ..schemas.message import MessageResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/rooms", tags=["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_id}/messages", response_model=list[MessageResponse])
|
||||||
|
async def get_messages(
|
||||||
|
room_id: UUID,
|
||||||
|
limit: int = 50,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Message)
|
||||||
|
.options(selectinload(Message.user))
|
||||||
|
.where(Message.room_id == room_id)
|
||||||
|
.order_by(Message.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
messages = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
MessageResponse(
|
||||||
|
id=msg.id,
|
||||||
|
room_id=msg.room_id,
|
||||||
|
user_id=msg.user_id,
|
||||||
|
username=msg.user.username,
|
||||||
|
text=msg.text,
|
||||||
|
created_at=msg.created_at,
|
||||||
|
)
|
||||||
|
for msg in reversed(messages)
|
||||||
|
]
|
||||||
248
backend/app/routers/rooms.py
Normal file
248
backend/app/routers/rooms.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models.user import User
|
||||||
|
from ..models.room import Room, RoomParticipant
|
||||||
|
from ..models.track import RoomQueue
|
||||||
|
from ..schemas.room import RoomCreate, RoomResponse, RoomDetailResponse, QueueAdd
|
||||||
|
from ..schemas.track import TrackResponse
|
||||||
|
from ..schemas.user import UserResponse
|
||||||
|
from ..services.auth import get_current_user
|
||||||
|
from ..services.sync import manager
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RoomResponse])
|
||||||
|
async def get_rooms(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Room, func.count(RoomParticipant.user_id).label("participants_count"))
|
||||||
|
.outerjoin(RoomParticipant)
|
||||||
|
.group_by(Room.id)
|
||||||
|
.order_by(Room.created_at.desc())
|
||||||
|
)
|
||||||
|
rooms = []
|
||||||
|
for room, count in result.all():
|
||||||
|
room_dict = {
|
||||||
|
"id": room.id,
|
||||||
|
"name": room.name,
|
||||||
|
"owner_id": room.owner_id,
|
||||||
|
"current_track_id": room.current_track_id,
|
||||||
|
"playback_position": room.playback_position,
|
||||||
|
"is_playing": room.is_playing,
|
||||||
|
"created_at": room.created_at,
|
||||||
|
"participants_count": count,
|
||||||
|
}
|
||||||
|
rooms.append(RoomResponse(**room_dict))
|
||||||
|
return rooms
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RoomResponse)
|
||||||
|
async def create_room(
|
||||||
|
room_data: RoomCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
room = Room(name=room_data.name, owner_id=current_user.id)
|
||||||
|
db.add(room)
|
||||||
|
await db.flush()
|
||||||
|
return RoomResponse(
|
||||||
|
id=room.id,
|
||||||
|
name=room.name,
|
||||||
|
owner_id=room.owner_id,
|
||||||
|
current_track_id=room.current_track_id,
|
||||||
|
playback_position=room.playback_position,
|
||||||
|
is_playing=room.is_playing,
|
||||||
|
created_at=room.created_at,
|
||||||
|
participants_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_id}", response_model=RoomDetailResponse)
|
||||||
|
async def get_room(room_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Room)
|
||||||
|
.options(
|
||||||
|
selectinload(Room.owner),
|
||||||
|
selectinload(Room.current_track),
|
||||||
|
selectinload(Room.participants).selectinload(RoomParticipant.user),
|
||||||
|
)
|
||||||
|
.where(Room.id == room_id)
|
||||||
|
)
|
||||||
|
room = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||||
|
|
||||||
|
return RoomDetailResponse(
|
||||||
|
id=room.id,
|
||||||
|
name=room.name,
|
||||||
|
owner=UserResponse.model_validate(room.owner),
|
||||||
|
current_track=TrackResponse.model_validate(room.current_track) if room.current_track else None,
|
||||||
|
playback_position=room.playback_position,
|
||||||
|
is_playing=room.is_playing,
|
||||||
|
created_at=room.created_at,
|
||||||
|
participants=[UserResponse.model_validate(p.user) for p in room.participants],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}")
|
||||||
|
async def delete_room(
|
||||||
|
room_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Room).where(Room.id == room_id))
|
||||||
|
room = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||||
|
|
||||||
|
if room.owner_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not room owner")
|
||||||
|
|
||||||
|
await db.delete(room)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{room_id}/join")
|
||||||
|
async def join_room(
|
||||||
|
room_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Room).where(Room.id == room_id))
|
||||||
|
room = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||||
|
|
||||||
|
# Check participant limit
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(RoomParticipant.user_id)).where(RoomParticipant.room_id == room_id)
|
||||||
|
)
|
||||||
|
count = result.scalar()
|
||||||
|
if count >= settings.max_room_participants:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Room is full")
|
||||||
|
|
||||||
|
# Check if already joined
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomParticipant).where(
|
||||||
|
RoomParticipant.room_id == room_id,
|
||||||
|
RoomParticipant.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
return {"status": "already joined"}
|
||||||
|
|
||||||
|
participant = RoomParticipant(room_id=room_id, user_id=current_user.id)
|
||||||
|
db.add(participant)
|
||||||
|
|
||||||
|
# Notify others
|
||||||
|
await manager.broadcast_to_room(
|
||||||
|
room_id,
|
||||||
|
{"type": "user_joined", "user": {"id": str(current_user.id), "username": current_user.username}},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "joined"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{room_id}/leave")
|
||||||
|
async def leave_room(
|
||||||
|
room_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomParticipant).where(
|
||||||
|
RoomParticipant.room_id == room_id,
|
||||||
|
RoomParticipant.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
participant = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if participant:
|
||||||
|
await db.delete(participant)
|
||||||
|
|
||||||
|
# Notify others
|
||||||
|
await manager.broadcast_to_room(
|
||||||
|
room_id,
|
||||||
|
{"type": "user_left", "user_id": str(current_user.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "left"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_id}/queue", response_model=list[TrackResponse])
|
||||||
|
async def get_queue(room_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomQueue)
|
||||||
|
.options(selectinload(RoomQueue.track))
|
||||||
|
.where(RoomQueue.room_id == room_id)
|
||||||
|
.order_by(RoomQueue.position)
|
||||||
|
)
|
||||||
|
queue_items = result.scalars().all()
|
||||||
|
return [TrackResponse.model_validate(item.track) for item in queue_items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{room_id}/queue")
|
||||||
|
async def add_to_queue(
|
||||||
|
room_id: UUID,
|
||||||
|
data: QueueAdd,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Get max position
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id)
|
||||||
|
)
|
||||||
|
max_pos = result.scalar() or 0
|
||||||
|
|
||||||
|
queue_item = RoomQueue(
|
||||||
|
room_id=room_id,
|
||||||
|
track_id=data.track_id,
|
||||||
|
position=max_pos + 1,
|
||||||
|
added_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(queue_item)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Notify others
|
||||||
|
await manager.broadcast_to_room(
|
||||||
|
room_id,
|
||||||
|
{"type": "queue_updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "added"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{room_id}/queue/{track_id}")
|
||||||
|
async def remove_from_queue(
|
||||||
|
room_id: UUID,
|
||||||
|
track_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomQueue).where(
|
||||||
|
RoomQueue.room_id == room_id,
|
||||||
|
RoomQueue.track_id == track_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queue_item = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if queue_item:
|
||||||
|
await db.delete(queue_item)
|
||||||
|
|
||||||
|
# Notify others
|
||||||
|
await manager.broadcast_to_room(
|
||||||
|
room_id,
|
||||||
|
{"type": "queue_updated"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "removed"}
|
||||||
222
backend/app/routers/tracks.py
Normal file
222
backend/app/routers/tracks.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import uuid
|
||||||
|
from urllib.parse import quote
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
from io import BytesIO
|
||||||
|
from ..database import get_db
|
||||||
|
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 ..config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
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()))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=TrackResponse)
|
||||||
|
async def upload_track(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: str = Form(None),
|
||||||
|
artist: str = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
# Check file type
|
||||||
|
if not file.content_type or not file.content_type.startswith("audio/"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File must be an audio file",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
max_size = settings.max_file_size_mb * 1024 * 1024
|
||||||
|
if file_size > max_size:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File size exceeds {settings.max_file_size_mb}MB limit",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check storage limit
|
||||||
|
if not await can_upload_file(file_size):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Storage limit exceeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get duration and metadata from MP3
|
||||||
|
try:
|
||||||
|
audio = MP3(BytesIO(content))
|
||||||
|
duration = int(audio.info.length * 1000) # Convert to milliseconds
|
||||||
|
|
||||||
|
# Extract ID3 tags if title/artist not provided
|
||||||
|
if not title or not artist:
|
||||||
|
tags = audio.tags
|
||||||
|
if tags:
|
||||||
|
# TIT2 = Title, TPE1 = Artist
|
||||||
|
if not title and tags.get("TIT2"):
|
||||||
|
title = str(tags.get("TIT2"))
|
||||||
|
if not artist and tags.get("TPE1"):
|
||||||
|
artist = str(tags.get("TPE1"))
|
||||||
|
|
||||||
|
# Fallback to filename if still no title
|
||||||
|
if not title:
|
||||||
|
title = file.filename.rsplit(".", 1)[0] if file.filename else "Unknown"
|
||||||
|
if not artist:
|
||||||
|
artist = "Unknown"
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Could not read audio file",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload to S3
|
||||||
|
s3_key = f"tracks/{uuid.uuid4()}.mp3"
|
||||||
|
await upload_file(content, s3_key)
|
||||||
|
|
||||||
|
# Create track record
|
||||||
|
track = Track(
|
||||||
|
title=title,
|
||||||
|
artist=artist,
|
||||||
|
duration=duration,
|
||||||
|
s3_key=s3_key,
|
||||||
|
file_size=file_size,
|
||||||
|
uploaded_by=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(track)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return track
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_id}", response_model=TrackWithUrl)
|
||||||
|
async def get_track(track_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(Track).where(Track.id == track_id))
|
||||||
|
track = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not track:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
|
||||||
|
|
||||||
|
url = generate_presigned_url(track.s3_key)
|
||||||
|
return TrackWithUrl(
|
||||||
|
id=track.id,
|
||||||
|
title=track.title,
|
||||||
|
artist=track.artist,
|
||||||
|
duration=track.duration,
|
||||||
|
file_size=track.file_size,
|
||||||
|
uploaded_by=track.uploaded_by,
|
||||||
|
created_at=track.created_at,
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{track_id}")
|
||||||
|
async def delete_track(
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(Track).where(Track.id == track_id))
|
||||||
|
track = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not track:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
|
||||||
|
|
||||||
|
if track.uploaded_by != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not track owner")
|
||||||
|
|
||||||
|
# Delete from S3
|
||||||
|
await delete_file(track.s3_key)
|
||||||
|
|
||||||
|
# Delete from DB
|
||||||
|
await db.delete(track)
|
||||||
|
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/storage/info")
|
||||||
|
async def get_storage_info(db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await db.execute(select(func.sum(Track.file_size)))
|
||||||
|
total_size = result.scalar() or 0
|
||||||
|
max_size = settings.max_storage_gb * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
return {
|
||||||
|
"used_bytes": total_size,
|
||||||
|
"max_bytes": max_size,
|
||||||
|
"used_gb": round(total_size / (1024 * 1024 * 1024), 2),
|
||||||
|
"max_gb": settings.max_storage_gb,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_id}/stream")
|
||||||
|
async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Stream audio file through backend with Range support (bypasses S3 SSL issues)"""
|
||||||
|
result = await db.execute(select(Track).where(Track.id == track_id))
|
||||||
|
track = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Parse Range header
|
||||||
|
range_header = request.headers.get("range")
|
||||||
|
|
||||||
|
if range_header:
|
||||||
|
# Parse "bytes=start-end"
|
||||||
|
range_match = range_header.replace("bytes=", "").split("-")
|
||||||
|
start = int(range_match[0]) if range_match[0] else 0
|
||||||
|
end = int(range_match[1]) if range_match[1] else file_size - 1
|
||||||
|
|
||||||
|
# Ensure valid range
|
||||||
|
if start >= file_size:
|
||||||
|
raise HTTPException(status_code=416, detail="Range not satisfiable")
|
||||||
|
|
||||||
|
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],
|
||||||
|
status_code=206,
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={
|
||||||
|
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(content_length),
|
||||||
|
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Encode filename for non-ASCII characters
|
||||||
|
encoded_filename = quote(f"{track.title}.mp3")
|
||||||
|
|
||||||
|
# No range - return full file
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="audio/mpeg",
|
||||||
|
headers={
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": str(file_size),
|
||||||
|
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
|
||||||
|
}
|
||||||
|
)
|
||||||
234
backend/app/routers/websocket.py
Normal file
234
backend/app/routers/websocket.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..database import get_db, async_session
|
||||||
|
from ..models.room import Room, RoomParticipant
|
||||||
|
from ..models.track import RoomQueue
|
||||||
|
from ..models.message import Message
|
||||||
|
from ..models.user import User
|
||||||
|
from ..services.sync import manager
|
||||||
|
from ..utils.security import decode_token
|
||||||
|
|
||||||
|
router = APIRouter(tags=["websocket"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_from_token(token: str) -> User | None:
|
||||||
|
payload = decode_token(token)
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(select(User).where(User.id == UUID(user_id)))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/rooms/{room_id}")
|
||||||
|
async def room_websocket(websocket: WebSocket, room_id: UUID):
|
||||||
|
# Get token from query params
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
if not token:
|
||||||
|
await websocket.close(code=4001, reason="No token provided")
|
||||||
|
return
|
||||||
|
|
||||||
|
user = await get_user_from_token(token)
|
||||||
|
if not user:
|
||||||
|
await websocket.close(code=4001, reason="Invalid token")
|
||||||
|
return
|
||||||
|
|
||||||
|
await manager.connect(websocket, room_id, user.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
message = json.loads(data)
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
if message["type"] == "player_action":
|
||||||
|
await handle_player_action(db, room_id, user, message)
|
||||||
|
elif message["type"] == "chat_message":
|
||||||
|
await handle_chat_message(db, room_id, user, message)
|
||||||
|
elif message["type"] == "sync_request":
|
||||||
|
await handle_sync_request(db, room_id, websocket)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(websocket, room_id, user.id)
|
||||||
|
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):
|
||||||
|
action = message.get("action")
|
||||||
|
result = await db.execute(select(Room).where(Room.id == room_id))
|
||||||
|
room = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "play":
|
||||||
|
room.is_playing = True
|
||||||
|
room.playback_position = message.get("position", room.playback_position or 0)
|
||||||
|
room.playback_started_at = datetime.utcnow()
|
||||||
|
elif action == "pause":
|
||||||
|
room.is_playing = False
|
||||||
|
room.playback_position = message.get("position", room.playback_position or 0)
|
||||||
|
room.playback_started_at = None
|
||||||
|
elif action == "seek":
|
||||||
|
room.playback_position = message.get("position", 0)
|
||||||
|
if room.is_playing:
|
||||||
|
room.playback_started_at = datetime.utcnow()
|
||||||
|
elif action == "next":
|
||||||
|
await play_next_track(db, room)
|
||||||
|
elif action == "prev":
|
||||||
|
await play_prev_track(db, room)
|
||||||
|
elif action == "set_track":
|
||||||
|
track_id = message.get("track_id")
|
||||||
|
if track_id:
|
||||||
|
room.current_track_id = UUID(track_id)
|
||||||
|
room.playback_position = 0
|
||||||
|
room.is_playing = True
|
||||||
|
room.playback_started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Get current track URL - use streaming endpoint to bypass S3 SSL issues
|
||||||
|
track_url = None
|
||||||
|
if room.current_track_id:
|
||||||
|
track_url = f"/api/tracks/{room.current_track_id}/stream"
|
||||||
|
|
||||||
|
# Calculate current position based on when playback started
|
||||||
|
current_position = room.playback_position or 0
|
||||||
|
if room.is_playing and room.playback_started_at:
|
||||||
|
elapsed = (datetime.utcnow() - room.playback_started_at).total_seconds() * 1000
|
||||||
|
current_position = int((room.playback_position or 0) + elapsed)
|
||||||
|
|
||||||
|
await manager.broadcast_to_room(
|
||||||
|
room_id,
|
||||||
|
{
|
||||||
|
"type": "player_state",
|
||||||
|
"is_playing": room.is_playing,
|
||||||
|
"position": current_position,
|
||||||
|
"current_track_id": str(room.current_track_id) if room.current_track_id else None,
|
||||||
|
"track_url": track_url,
|
||||||
|
"server_time": datetime.utcnow().isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def play_next_track(db: AsyncSession, room: Room):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomQueue)
|
||||||
|
.where(RoomQueue.room_id == room.id)
|
||||||
|
.order_by(RoomQueue.position)
|
||||||
|
)
|
||||||
|
queue = result.scalars().all()
|
||||||
|
|
||||||
|
if not queue:
|
||||||
|
room.current_track_id = None
|
||||||
|
room.is_playing = False
|
||||||
|
room.playback_started_at = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find current track in queue
|
||||||
|
current_index = -1
|
||||||
|
for i, item in enumerate(queue):
|
||||||
|
if item.track_id == room.current_track_id:
|
||||||
|
current_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# Play next or first
|
||||||
|
next_index = (current_index + 1) % len(queue)
|
||||||
|
room.current_track_id = queue[next_index].track_id
|
||||||
|
room.playback_position = 0
|
||||||
|
room.is_playing = True
|
||||||
|
room.playback_started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
async def play_prev_track(db: AsyncSession, room: Room):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RoomQueue)
|
||||||
|
.where(RoomQueue.room_id == room.id)
|
||||||
|
.order_by(RoomQueue.position)
|
||||||
|
)
|
||||||
|
queue = result.scalars().all()
|
||||||
|
|
||||||
|
if not queue:
|
||||||
|
room.current_track_id = None
|
||||||
|
room.is_playing = False
|
||||||
|
room.playback_started_at = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find current track in queue
|
||||||
|
current_index = 0
|
||||||
|
for i, item in enumerate(queue):
|
||||||
|
if item.track_id == room.current_track_id:
|
||||||
|
current_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# Play prev or last
|
||||||
|
prev_index = (current_index - 1) % len(queue)
|
||||||
|
room.current_track_id = queue[prev_index].track_id
|
||||||
|
room.playback_position = 0
|
||||||
|
room.is_playing = True
|
||||||
|
room.playback_started_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_chat_message(db: AsyncSession, room_id: UUID, user: User, message: dict):
|
||||||
|
text = message.get("text", "").strip()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = Message(room_id=room_id, user_id=user.id, text=text)
|
||||||
|
db.add(msg)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await manager.broadcast_to_room(
|
||||||
|
room_id,
|
||||||
|
{
|
||||||
|
"type": "chat_message",
|
||||||
|
"id": str(msg.id),
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"text": text,
|
||||||
|
"created_at": msg.created_at.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_sync_request(db: AsyncSession, room_id: UUID, websocket: WebSocket):
|
||||||
|
result = await db.execute(
|
||||||
|
select(Room).options(selectinload(Room.current_track)).where(Room.id == room_id)
|
||||||
|
)
|
||||||
|
room = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
return
|
||||||
|
|
||||||
|
track_url = None
|
||||||
|
if room.current_track_id:
|
||||||
|
track_url = f"/api/tracks/{room.current_track_id}/stream"
|
||||||
|
|
||||||
|
# Calculate current position based on when playback started
|
||||||
|
current_position = room.playback_position or 0
|
||||||
|
if room.is_playing and room.playback_started_at:
|
||||||
|
elapsed = (datetime.utcnow() - room.playback_started_at).total_seconds() * 1000
|
||||||
|
current_position = int((room.playback_position or 0) + elapsed)
|
||||||
|
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "sync_state",
|
||||||
|
"is_playing": room.is_playing,
|
||||||
|
"position": current_position,
|
||||||
|
"current_track_id": str(room.current_track_id) if room.current_track_id else None,
|
||||||
|
"track_url": track_url,
|
||||||
|
"server_time": datetime.utcnow().isoformat(),
|
||||||
|
})
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
19
backend/app/schemas/message.py
Normal file
19
backend/app/schemas/message.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
room_id: UUID
|
||||||
|
user_id: UUID
|
||||||
|
username: str
|
||||||
|
text: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
47
backend/app/schemas/room.py
Normal file
47
backend/app/schemas/room.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from .user import UserResponse
|
||||||
|
from .track import TrackResponse
|
||||||
|
|
||||||
|
|
||||||
|
class RoomCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoomResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
owner_id: UUID
|
||||||
|
current_track_id: Optional[UUID] = None
|
||||||
|
playback_position: int
|
||||||
|
is_playing: bool
|
||||||
|
created_at: datetime
|
||||||
|
participants_count: int = 0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class RoomDetailResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
owner: UserResponse
|
||||||
|
current_track: Optional[TrackResponse] = None
|
||||||
|
playback_position: int
|
||||||
|
is_playing: bool
|
||||||
|
created_at: datetime
|
||||||
|
participants: list[UserResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerAction(BaseModel):
|
||||||
|
action: str # play, pause, seek, next, prev
|
||||||
|
position: Optional[int] = None # for seek
|
||||||
|
|
||||||
|
|
||||||
|
class QueueAdd(BaseModel):
|
||||||
|
track_id: UUID
|
||||||
25
backend/app/schemas/track.py
Normal file
25
backend/app/schemas/track.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TrackCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
|
||||||
|
|
||||||
|
class TrackResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
title: str
|
||||||
|
artist: str
|
||||||
|
duration: int
|
||||||
|
file_size: int
|
||||||
|
uploaded_by: UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TrackWithUrl(TrackResponse):
|
||||||
|
url: str
|
||||||
33
backend/app/schemas/user.py
Normal file
33
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
user_id: UUID | None = None
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
42
backend/app/services/auth.py
Normal file
42
backend/app/services/auth.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from ..models.user import User
|
||||||
|
from ..database import get_db
|
||||||
|
from ..utils.security import decode_token
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
token = credentials.credentials
|
||||||
|
payload = decode_token(token)
|
||||||
|
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid token",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == UUID(user_id)))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
77
backend/app/services/s3.py
Normal file
77
backend/app/services/s3.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import boto3
|
||||||
|
import urllib3
|
||||||
|
from botocore.config import Config
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
# Suppress SSL warnings for self-signed certificate
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_s3_client():
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint_url,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
verify=False, # FirstVDS uses self-signed certificate
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_total_storage_size() -> int:
|
||||||
|
"""Returns total size of all objects in bucket in bytes"""
|
||||||
|
client = get_s3_client()
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
paginator = client.get_paginator("list_objects_v2")
|
||||||
|
for page in paginator.paginate(Bucket=settings.s3_bucket_name):
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
total_size += obj["Size"]
|
||||||
|
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
|
||||||
|
async def can_upload_file(file_size: int) -> bool:
|
||||||
|
"""Check if file can be uploaded without exceeding storage limit"""
|
||||||
|
max_bytes = settings.max_storage_gb * 1024 * 1024 * 1024
|
||||||
|
current_size = await get_total_storage_size()
|
||||||
|
return (current_size + file_size) <= max_bytes
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(file_content: bytes, s3_key: str, content_type: str = "audio/mpeg") -> str:
|
||||||
|
"""Upload file to S3 and return the key"""
|
||||||
|
client = get_s3_client()
|
||||||
|
client.put_object(
|
||||||
|
Bucket=settings.s3_bucket_name,
|
||||||
|
Key=s3_key,
|
||||||
|
Body=file_content,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
return s3_key
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_file(s3_key: str) -> None:
|
||||||
|
"""Delete file from S3"""
|
||||||
|
client = get_s3_client()
|
||||||
|
client.delete_object(Bucket=settings.s3_bucket_name, Key=s3_key)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_presigned_url(s3_key: str, expiration: int = 3600) -> str:
|
||||||
|
"""Generate presigned URL for file access"""
|
||||||
|
client = get_s3_client()
|
||||||
|
url = client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": settings.s3_bucket_name, "Key": s3_key},
|
||||||
|
ExpiresIn=expiration,
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_content(s3_key: str) -> bytes:
|
||||||
|
"""Get full file content from S3"""
|
||||||
|
client = get_s3_client()
|
||||||
|
response = client.get_object(Bucket=settings.s3_bucket_name, Key=s3_key)
|
||||||
|
return response["Body"].read()
|
||||||
48
backend/app/services/sync.py
Normal file
48
backend/app/services/sync.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from typing import Dict, Set
|
||||||
|
from fastapi import WebSocket
|
||||||
|
from uuid import UUID
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
# room_id -> set of (websocket, user_id)
|
||||||
|
self.active_connections: Dict[UUID, Set[tuple[WebSocket, UUID]]] = {}
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket, room_id: UUID, user_id: UUID):
|
||||||
|
await websocket.accept()
|
||||||
|
if room_id not in self.active_connections:
|
||||||
|
self.active_connections[room_id] = set()
|
||||||
|
self.active_connections[room_id].add((websocket, user_id))
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket, room_id: UUID, user_id: UUID):
|
||||||
|
if room_id in self.active_connections:
|
||||||
|
self.active_connections[room_id].discard((websocket, user_id))
|
||||||
|
if not self.active_connections[room_id]:
|
||||||
|
del self.active_connections[room_id]
|
||||||
|
|
||||||
|
async def broadcast_to_room(self, room_id: UUID, message: dict, exclude_user: UUID = None):
|
||||||
|
if room_id not in self.active_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
message_json = json.dumps(message, default=str)
|
||||||
|
disconnected = []
|
||||||
|
|
||||||
|
for websocket, user_id in self.active_connections[room_id]:
|
||||||
|
if exclude_user and user_id == exclude_user:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await websocket.send_text(message_json)
|
||||||
|
except Exception:
|
||||||
|
disconnected.append((websocket, user_id))
|
||||||
|
|
||||||
|
for conn in disconnected:
|
||||||
|
self.active_connections[room_id].discard(conn)
|
||||||
|
|
||||||
|
def get_room_user_count(self, room_id: UUID) -> int:
|
||||||
|
if room_id not in self.active_connections:
|
||||||
|
return 0
|
||||||
|
return len(self.active_connections[room_id])
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
35
backend/app/utils/security.py
Normal file
35
backend/app/utils/security.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from ..config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
sqlalchemy[asyncio]==2.0.25
|
||||||
|
asyncpg==0.29.0
|
||||||
|
alembic==1.13.1
|
||||||
|
pydantic[email]==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
python-multipart==0.0.6
|
||||||
|
boto3==1.34.25
|
||||||
|
mutagen==1.47.0
|
||||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: enigfm
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "4002:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@db:5432/enigfm
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL}
|
||||||
|
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||||
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
|
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-enigfm}
|
||||||
|
S3_REGION: ${S3_REGION:-ru-1}
|
||||||
|
ports:
|
||||||
|
- "4001:8000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "4000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:4001
|
||||||
|
VITE_WS_URL=ws://localhost:4001
|
||||||
20
frontend/Dockerfile
Normal file
20
frontend/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine as build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EnigFM - Слушай музыку вместе</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
frontend/nginx.conf
Normal file
38
frontend/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket proxy
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "enigfm-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.15",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"axios": "^1.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/App.vue
Normal file
28
frontend/src/App.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<Header />
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Header from './components/common/Header.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
97
frontend/src/assets/styles/main.css
Normal file
97
frontend/src/assets/styles/main.css
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #6c63ff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5a52d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #2d2d44;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #3d3d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ff4757;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #ff3344;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #3d3d5c;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #16162a;
|
||||||
|
color: #eee;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6c63ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #16162a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #2d2d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ff4757;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
34
frontend/src/components/chat/ChatMessage.vue
Normal file
34
frontend/src/components/chat/ChatMessage.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message">
|
||||||
|
<span class="message-author">{{ message.username }}</span>
|
||||||
|
<span class="message-text">{{ message.text }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-author {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c63ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
117
frontend/src/components/chat/ChatWindow.vue
Normal file
117
frontend/src/components/chat/ChatWindow.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat card">
|
||||||
|
<h3>Чат</h3>
|
||||||
|
<div class="messages" ref="messagesRef">
|
||||||
|
<ChatMessage
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:message="msg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="sendMessage" class="chat-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="newMessage"
|
||||||
|
placeholder="Написать сообщение..."
|
||||||
|
:disabled="!ws.connected"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="!newMessage.trim()">
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
|
import api from '../../composables/useApi'
|
||||||
|
import ChatMessage from './ChatMessage.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
roomId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = ref([])
|
||||||
|
const newMessage = ref('')
|
||||||
|
const messagesRef = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const response = await api.get(`/api/rooms/${props.roomId}/messages`)
|
||||||
|
messages.value = response.data
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for new messages from WebSocket
|
||||||
|
watch(() => props.ws, (wsObj) => {
|
||||||
|
if (wsObj?.messages) {
|
||||||
|
watch(wsObj.messages, (msgs) => {
|
||||||
|
const lastMsg = msgs[msgs.length - 1]
|
||||||
|
if (lastMsg?.type === 'chat_message') {
|
||||||
|
messages.value.push({
|
||||||
|
id: lastMsg.id,
|
||||||
|
user_id: lastMsg.user_id,
|
||||||
|
username: lastMsg.username,
|
||||||
|
text: lastMsg.text,
|
||||||
|
created_at: lastMsg.created_at
|
||||||
|
})
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
if (!newMessage.value.trim()) return
|
||||||
|
props.ws.sendChatMessage(newMessage.value)
|
||||||
|
newMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesRef.value) {
|
||||||
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
78
frontend/src/components/common/Header.vue
Normal file
78
frontend/src/components/common/Header.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<router-link to="/" class="logo">EnigFM</router-link>
|
||||||
|
<nav class="nav">
|
||||||
|
<template v-if="authStore.isAuthenticated">
|
||||||
|
<router-link to="/">Комнаты</router-link>
|
||||||
|
<router-link to="/tracks">Треки</router-link>
|
||||||
|
<span class="username">{{ authStore.user?.username }}</span>
|
||||||
|
<button class="btn-secondary" @click="logout">Выйти</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<router-link to="/login">Войти</router-link>
|
||||||
|
<router-link to="/register">Регистрация</router-link>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
background: #16162a;
|
||||||
|
border-bottom: 1px solid #2d2d44;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6c63ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .nav a.router-link-active {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
color: #6c63ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
frontend/src/components/common/Modal.vue
Normal file
80
frontend/src/components/common/Modal.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<button class="close-btn" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['close'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #16162a;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #2d2d44;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #2d2d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
frontend/src/components/player/AudioPlayer.vue
Normal file
107
frontend/src/components/player/AudioPlayer.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<div class="audio-player card">
|
||||||
|
<div class="track-info">
|
||||||
|
<div v-if="playerStore.currentTrack" class="track-details">
|
||||||
|
<span class="track-title">{{ currentTrackInfo?.title || 'Трек' }}</span>
|
||||||
|
<span class="track-artist">{{ currentTrackInfo?.artist || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-track">
|
||||||
|
Выберите трек для воспроизведения
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
:position="playerStore.position"
|
||||||
|
:duration="playerStore.duration"
|
||||||
|
@seek="handleSeek"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlayerControls
|
||||||
|
:is-playing="playerStore.isPlaying"
|
||||||
|
@play="handlePlay"
|
||||||
|
@pause="handlePause"
|
||||||
|
@next="handleNext"
|
||||||
|
@prev="handlePrev"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VolumeControl
|
||||||
|
:volume="playerStore.volume"
|
||||||
|
@change="handleVolumeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { usePlayerStore } from '../../stores/player'
|
||||||
|
import { useTracksStore } from '../../stores/tracks'
|
||||||
|
import ProgressBar from './ProgressBar.vue'
|
||||||
|
import PlayerControls from './PlayerControls.vue'
|
||||||
|
import VolumeControl from './VolumeControl.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['player-action'])
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
const tracksStore = useTracksStore()
|
||||||
|
|
||||||
|
const currentTrackInfo = computed(() => {
|
||||||
|
if (!playerStore.currentTrack?.id) return null
|
||||||
|
return tracksStore.tracks.find(t => t.id === playerStore.currentTrack.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePlay() {
|
||||||
|
emit('player-action', 'play', playerStore.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePause() {
|
||||||
|
emit('player-action', 'pause', playerStore.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSeek(position) {
|
||||||
|
emit('player-action', 'seek', position)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
emit('player-action', 'next')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrev() {
|
||||||
|
emit('player-action', 'prev')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVolumeChange(volume) {
|
||||||
|
playerStore.setVolume(volume)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.audio-player {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-track {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
frontend/src/components/player/PlayerControls.vue
Normal file
80
frontend/src/components/player/PlayerControls.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="player-controls">
|
||||||
|
<button class="control-btn" @click="$emit('prev')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="control-btn play-btn" @click="isPlaying ? $emit('pause') : $emit('play')">
|
||||||
|
<svg v-if="isPlaying" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="control-btn" @click="$emit('next')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
isPlaying: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['play', 'pause', 'next', 'prev'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #eee;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
background: #2d2d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
background: #6c63ff;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn:hover {
|
||||||
|
background: #5a52d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
frontend/src/components/player/ProgressBar.vue
Normal file
82
frontend/src/components/player/ProgressBar.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="progress-container">
|
||||||
|
<span class="time">{{ formatTime(position) }}</span>
|
||||||
|
<div class="progress-bar" @click="handleClick" ref="progressRef">
|
||||||
|
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="time">{{ formatTime(duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
position: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['seek'])
|
||||||
|
|
||||||
|
const progressRef = ref(null)
|
||||||
|
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
if (props.duration === 0) return 0
|
||||||
|
return (props.position / props.duration) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(ms) {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
if (!progressRef.value || props.duration === 0) return
|
||||||
|
const rect = progressRef.value.getBoundingClientRect()
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width
|
||||||
|
const newPosition = Math.floor(percent * props.duration)
|
||||||
|
emit('seek', newPosition)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: #2d2d44;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #6c63ff;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.1s;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
frontend/src/components/player/VolumeControl.vue
Normal file
103
frontend/src/components/player/VolumeControl.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="volume-control">
|
||||||
|
<button class="volume-btn" @click="toggleMute">
|
||||||
|
<svg v-if="volume === 0" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="volume < 50" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="volume"
|
||||||
|
@input="$emit('change', parseInt($event.target.value))"
|
||||||
|
class="volume-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
volume: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['change'])
|
||||||
|
|
||||||
|
const previousVolume = ref(100)
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
if (props.volume > 0) {
|
||||||
|
previousVolume.value = props.volume
|
||||||
|
emit('change', 0)
|
||||||
|
} else {
|
||||||
|
emit('change', previousVolume.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.volume-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #aaa;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 100px;
|
||||||
|
height: 4px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: #2d2d44;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #6c63ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #6c63ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
61
frontend/src/components/room/ParticipantsList.vue
Normal file
61
frontend/src/components/room/ParticipantsList.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="participants card">
|
||||||
|
<h3>Участники ({{ participants.length }})</h3>
|
||||||
|
<div class="participants-list">
|
||||||
|
<div
|
||||||
|
v-for="participant in participants"
|
||||||
|
:key="participant.id"
|
||||||
|
class="participant"
|
||||||
|
>
|
||||||
|
<div class="avatar">{{ participant.username.charAt(0).toUpperCase() }}</div>
|
||||||
|
<span class="username">{{ participant.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
participants: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.participants h3 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6c63ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
frontend/src/components/room/Queue.vue
Normal file
98
frontend/src/components/room/Queue.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="queue">
|
||||||
|
<div v-if="queue.length === 0" class="empty-queue">
|
||||||
|
Очередь пуста
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(track, index) in queue"
|
||||||
|
:key="track.id"
|
||||||
|
class="queue-item"
|
||||||
|
@click="$emit('play-track', track)"
|
||||||
|
>
|
||||||
|
<span class="queue-index">{{ index + 1 }}</span>
|
||||||
|
<div class="queue-track-info">
|
||||||
|
<span class="queue-track-title">{{ track.title }}</span>
|
||||||
|
<span class="queue-track-artist">{{ track.artist }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="queue-duration">{{ formatDuration(track.duration) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
queue: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['play-track'])
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.queue {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-queue {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item:hover {
|
||||||
|
background: #2d2d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-index {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-track-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-track-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-track-artist {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-duration {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
frontend/src/components/room/RoomCard.vue
Normal file
64
frontend/src/components/room/RoomCard.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="room-card card">
|
||||||
|
<h3>{{ room.name }}</h3>
|
||||||
|
<div class="room-info">
|
||||||
|
<span class="participants">{{ room.participants_count }} участников</span>
|
||||||
|
<span v-if="room.is_playing" class="playing">Играет</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
room: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.room-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #6c63ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-card h3 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing {
|
||||||
|
color: #2ed573;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing::before {
|
||||||
|
content: '';
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #2ed573;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
frontend/src/components/tracks/TrackItem.vue
Normal file
103
frontend/src/components/tracks/TrackItem.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="track-item" @click="selectable && $emit('select')">
|
||||||
|
<div class="track-info">
|
||||||
|
<span class="track-title">{{ track.title }}</span>
|
||||||
|
<span class="track-artist">{{ track.artist }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="selectable"
|
||||||
|
class="btn-primary add-btn"
|
||||||
|
@click.stop="$emit('select')"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!selectable"
|
||||||
|
class="btn-danger delete-btn"
|
||||||
|
@click.stop="$emit('delete')"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
track: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['select', 'delete'])
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.track-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background: #2d2d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-duration {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
48
frontend/src/components/tracks/TrackList.vue
Normal file
48
frontend/src/components/tracks/TrackList.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="track-list">
|
||||||
|
<div v-if="tracks.length === 0" class="empty">
|
||||||
|
Нет треков
|
||||||
|
</div>
|
||||||
|
<TrackItem
|
||||||
|
v-for="track in tracks"
|
||||||
|
:key="track.id"
|
||||||
|
:track="track"
|
||||||
|
:selectable="selectable"
|
||||||
|
@select="$emit('select', track)"
|
||||||
|
@delete="$emit('delete', track)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import TrackItem from './TrackItem.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
tracks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['select', 'delete'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.track-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
105
frontend/src/components/tracks/UploadTrack.vue
Normal file
105
frontend/src/components/tracks/UploadTrack.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleUpload" class="upload-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>MP3 файл (макс. 10MB)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="audio/mpeg,audio/mp3"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
required
|
||||||
|
ref="fileInput"
|
||||||
|
/>
|
||||||
|
<small class="hint">Название и исполнитель будут взяты из тегов файла</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название <span class="optional">(необязательно)</span></label>
|
||||||
|
<input type="text" v-model="title" placeholder="Оставьте пустым для автоопределения" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Исполнитель <span class="optional">(необязательно)</span></label>
|
||||||
|
<input type="text" v-model="artist" placeholder="Оставьте пустым для автоопределения" />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="uploading">
|
||||||
|
{{ uploading ? 'Загрузка...' : 'Загрузить' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useTracksStore } from '../../stores/tracks'
|
||||||
|
|
||||||
|
const emit = defineEmits(['uploaded'])
|
||||||
|
|
||||||
|
const tracksStore = useTracksStore()
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const artist = ref('')
|
||||||
|
const file = ref(null)
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const error = ref('')
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
function handleFileSelect(e) {
|
||||||
|
const selectedFile = e.target.files[0]
|
||||||
|
if (!selectedFile) return
|
||||||
|
|
||||||
|
// Check file size (10MB)
|
||||||
|
if (selectedFile.size > 10 * 1024 * 1024) {
|
||||||
|
error.value = 'Файл слишком большой (макс. 10MB)'
|
||||||
|
fileInput.value.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file.value = selectedFile
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!file.value) {
|
||||||
|
error.value = 'Выберите файл'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tracksStore.uploadTrack(file.value, title.value, artist.value)
|
||||||
|
title.value = ''
|
||||||
|
artist.value = ''
|
||||||
|
file.value = null
|
||||||
|
fileInput.value.value = ''
|
||||||
|
emit('uploaded')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.detail || 'Ошибка загрузки'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form input[type="file"] {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional {
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
26
frontend/src/composables/useApi.js
Normal file
26
frontend/src/composables/useApi.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || '',
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
117
frontend/src/composables/usePlayer.js
Normal file
117
frontend/src/composables/usePlayer.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
import { usePlayerStore } from '../stores/player'
|
||||||
|
|
||||||
|
export function usePlayer(onTrackEnded = null) {
|
||||||
|
const audio = ref(null)
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
let endedCallback = onTrackEnded
|
||||||
|
|
||||||
|
function setOnTrackEnded(callback) {
|
||||||
|
endedCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAudio() {
|
||||||
|
audio.value = new Audio()
|
||||||
|
audio.value.volume = playerStore.volume / 100
|
||||||
|
|
||||||
|
audio.value.addEventListener('timeupdate', () => {
|
||||||
|
playerStore.setPosition(Math.floor(audio.value.currentTime * 1000))
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.value.addEventListener('loadedmetadata', () => {
|
||||||
|
playerStore.setDuration(Math.floor(audio.value.duration * 1000))
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.value.addEventListener('ended', () => {
|
||||||
|
if (endedCallback) {
|
||||||
|
endedCallback()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTrack(url) {
|
||||||
|
if (!audio.value) initAudio()
|
||||||
|
// If URL is relative, prepend API base URL
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||||
|
const fullUrl = url.startsWith('/') ? `${apiUrl}${url}` : url
|
||||||
|
audio.value.src = fullUrl
|
||||||
|
audio.value.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.play().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(positionMs) {
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.currentTime = positionMs / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(volume) {
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.volume = volume / 100
|
||||||
|
}
|
||||||
|
playerStore.setVolume(volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncToState(state) {
|
||||||
|
// Initialize audio if needed
|
||||||
|
if (!audio.value) {
|
||||||
|
initAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.track_url && state.track_url !== playerStore.currentTrackUrl) {
|
||||||
|
loadTrack(state.track_url)
|
||||||
|
playerStore.currentTrackUrl = state.track_url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.position !== undefined) {
|
||||||
|
const diff = Math.abs(state.position - playerStore.position)
|
||||||
|
// Sync if difference > 2 seconds
|
||||||
|
if (diff > 2000) {
|
||||||
|
seek(state.position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.is_playing) {
|
||||||
|
play()
|
||||||
|
} else {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch volume changes
|
||||||
|
watch(() => playerStore.volume, (newVolume) => {
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.volume = newVolume / 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (audio.value) {
|
||||||
|
audio.value.pause()
|
||||||
|
audio.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
audio,
|
||||||
|
initAudio,
|
||||||
|
loadTrack,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
syncToState,
|
||||||
|
setOnTrackEnded,
|
||||||
|
}
|
||||||
|
}
|
||||||
81
frontend/src/composables/useWebSocket.js
Normal file
81
frontend/src/composables/useWebSocket.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
export function useWebSocket(roomId, onMessage = null) {
|
||||||
|
const ws = ref(null)
|
||||||
|
const connected = ref(false)
|
||||||
|
const messages = ref([])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const wsUrl = import.meta.env.VITE_WS_URL || window.location.origin.replace('http', 'ws')
|
||||||
|
ws.value = new WebSocket(`${wsUrl}/ws/rooms/${roomId}?token=${authStore.token}`)
|
||||||
|
|
||||||
|
ws.value.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
// Request sync on connect
|
||||||
|
send({ type: 'sync_request' })
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.value.onclose = () => {
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.value.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.value.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
messages.value.push(data)
|
||||||
|
if (onMessage) {
|
||||||
|
onMessage(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(data) {
|
||||||
|
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
|
||||||
|
ws.value.send(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (ws.value) {
|
||||||
|
ws.value.close()
|
||||||
|
ws.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPlayerAction(action, position = null, trackId = null) {
|
||||||
|
send({
|
||||||
|
type: 'player_action',
|
||||||
|
action,
|
||||||
|
position,
|
||||||
|
track_id: trackId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendChatMessage(text) {
|
||||||
|
send({
|
||||||
|
type: 'chat_message',
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ws,
|
||||||
|
connected,
|
||||||
|
messages,
|
||||||
|
connect,
|
||||||
|
send,
|
||||||
|
disconnect,
|
||||||
|
sendPlayerAction,
|
||||||
|
sendChatMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/src/main.js
Normal file
12
frontend/src/main.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/styles/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
53
frontend/src/router/index.js
Normal file
53
frontend/src/router/index.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: () => import('../views/HomeView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('../views/LoginView.vue'),
|
||||||
|
meta: { guest: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: () => import('../views/RegisterView.vue'),
|
||||||
|
meta: { guest: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/room/:id',
|
||||||
|
name: 'Room',
|
||||||
|
component: () => import('../views/RoomView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tracks',
|
||||||
|
name: 'Tracks',
|
||||||
|
component: () => import('../views/TracksView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next({ name: 'Login' })
|
||||||
|
} else if (to.meta.guest && authStore.isAuthenticated) {
|
||||||
|
next({ name: 'Home' })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
55
frontend/src/stores/auth.js
Normal file
55
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import api from '../composables/useApi'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('token') || null)
|
||||||
|
const user = ref(null)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
async function login(email, password) {
|
||||||
|
const response = await api.post('/api/auth/login', { email, password })
|
||||||
|
token.value = response.data.access_token
|
||||||
|
localStorage.setItem('token', token.value)
|
||||||
|
await fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(username, email, password) {
|
||||||
|
const response = await api.post('/api/auth/register', { username, email, password })
|
||||||
|
token.value = response.data.access_token
|
||||||
|
localStorage.setItem('token', token.value)
|
||||||
|
await fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUser() {
|
||||||
|
if (!token.value) return
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/auth/me')
|
||||||
|
user.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
if (token.value) {
|
||||||
|
fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
fetchUser,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
71
frontend/src/stores/player.js
Normal file
71
frontend/src/stores/player.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const usePlayerStore = defineStore('player', () => {
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const currentTrack = ref(null)
|
||||||
|
const currentTrackUrl = ref(null)
|
||||||
|
const position = ref(0)
|
||||||
|
const duration = ref(0)
|
||||||
|
const volume = ref(100)
|
||||||
|
|
||||||
|
function setPlayerState(state) {
|
||||||
|
isPlaying.value = state.is_playing
|
||||||
|
position.value = state.position
|
||||||
|
if (state.current_track_id) {
|
||||||
|
currentTrack.value = { id: state.current_track_id }
|
||||||
|
}
|
||||||
|
if (state.track_url) {
|
||||||
|
currentTrackUrl.value = state.track_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrack(track, url) {
|
||||||
|
currentTrack.value = track
|
||||||
|
currentTrackUrl.value = url
|
||||||
|
position.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPosition(pos) {
|
||||||
|
position.value = pos
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDuration(dur) {
|
||||||
|
duration.value = dur
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(vol) {
|
||||||
|
volume.value = vol
|
||||||
|
localStorage.setItem('volume', vol)
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
isPlaying.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved volume
|
||||||
|
const savedVolume = localStorage.getItem('volume')
|
||||||
|
if (savedVolume) {
|
||||||
|
volume.value = parseInt(savedVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying,
|
||||||
|
currentTrack,
|
||||||
|
currentTrackUrl,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
setPlayerState,
|
||||||
|
setTrack,
|
||||||
|
setPosition,
|
||||||
|
setDuration,
|
||||||
|
setVolume,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
}
|
||||||
|
})
|
||||||
85
frontend/src/stores/room.js
Normal file
85
frontend/src/stores/room.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '../composables/useApi'
|
||||||
|
|
||||||
|
export const useRoomStore = defineStore('room', () => {
|
||||||
|
const rooms = ref([])
|
||||||
|
const currentRoom = ref(null)
|
||||||
|
const participants = ref([])
|
||||||
|
const queue = ref([])
|
||||||
|
|
||||||
|
async function fetchRooms() {
|
||||||
|
const response = await api.get('/api/rooms')
|
||||||
|
rooms.value = response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoom(roomId) {
|
||||||
|
const response = await api.get(`/api/rooms/${roomId}`)
|
||||||
|
currentRoom.value = response.data
|
||||||
|
participants.value = response.data.participants
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoom(name) {
|
||||||
|
const response = await api.post('/api/rooms', { name })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRoom(roomId) {
|
||||||
|
await api.delete(`/api/rooms/${roomId}`)
|
||||||
|
rooms.value = rooms.value.filter(r => r.id !== roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinRoom(roomId) {
|
||||||
|
await api.post(`/api/rooms/${roomId}/join`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaveRoom(roomId) {
|
||||||
|
await api.post(`/api/rooms/${roomId}/leave`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQueue(roomId) {
|
||||||
|
const response = await api.get(`/api/rooms/${roomId}/queue`)
|
||||||
|
queue.value = response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToQueue(roomId, trackId) {
|
||||||
|
await api.post(`/api/rooms/${roomId}/queue`, { track_id: trackId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFromQueue(roomId, trackId) {
|
||||||
|
await api.delete(`/api/rooms/${roomId}/queue/${trackId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParticipants(newParticipants) {
|
||||||
|
participants.value = newParticipants
|
||||||
|
}
|
||||||
|
|
||||||
|
function addParticipant(user) {
|
||||||
|
if (!participants.value.find(p => p.id === user.id)) {
|
||||||
|
participants.value.push(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeParticipant(userId) {
|
||||||
|
participants.value = participants.value.filter(p => p.id !== userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rooms,
|
||||||
|
currentRoom,
|
||||||
|
participants,
|
||||||
|
queue,
|
||||||
|
fetchRooms,
|
||||||
|
fetchRoom,
|
||||||
|
createRoom,
|
||||||
|
deleteRoom,
|
||||||
|
joinRoom,
|
||||||
|
leaveRoom,
|
||||||
|
fetchQueue,
|
||||||
|
addToQueue,
|
||||||
|
removeFromQueue,
|
||||||
|
updateParticipants,
|
||||||
|
addParticipant,
|
||||||
|
removeParticipant,
|
||||||
|
}
|
||||||
|
})
|
||||||
50
frontend/src/stores/tracks.js
Normal file
50
frontend/src/stores/tracks.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import api from '../composables/useApi'
|
||||||
|
|
||||||
|
export const useTracksStore = defineStore('tracks', () => {
|
||||||
|
const tracks = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchTracks() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/tracks')
|
||||||
|
tracks.value = response.data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadTrack(file, title, artist) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('title', title)
|
||||||
|
formData.append('artist', artist)
|
||||||
|
|
||||||
|
const response = await api.post('/api/tracks/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
tracks.value.unshift(response.data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTrack(trackId) {
|
||||||
|
await api.delete(`/api/tracks/${trackId}`)
|
||||||
|
tracks.value = tracks.value.filter(t => t.id !== trackId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTrackUrl(trackId) {
|
||||||
|
const response = await api.get(`/api/tracks/${trackId}`)
|
||||||
|
return response.data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracks,
|
||||||
|
loading,
|
||||||
|
fetchTracks,
|
||||||
|
uploadTrack,
|
||||||
|
deleteTrack,
|
||||||
|
getTrackUrl,
|
||||||
|
}
|
||||||
|
})
|
||||||
109
frontend/src/views/HomeView.vue
Normal file
109
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<div class="header-section">
|
||||||
|
<h1>Комнаты</h1>
|
||||||
|
<button v-if="authStore.isAuthenticated" class="btn-primary" @click="showCreateModal = true">
|
||||||
|
Создать комнату
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">Загрузка...</div>
|
||||||
|
|
||||||
|
<div v-else-if="roomStore.rooms.length === 0" class="empty">
|
||||||
|
<p>Пока нет комнат. Создайте первую!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rooms-grid">
|
||||||
|
<RoomCard
|
||||||
|
v-for="room in roomStore.rooms"
|
||||||
|
:key="room.id"
|
||||||
|
:room="room"
|
||||||
|
@click="goToRoom(room.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-if="showCreateModal" title="Создать комнату" @close="showCreateModal = false">
|
||||||
|
<form @submit.prevent="createRoom">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название комнаты</label>
|
||||||
|
<input type="text" v-model="newRoomName" required placeholder="Моя комната" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="creating">
|
||||||
|
{{ creating ? 'Создание...' : 'Создать' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useRoomStore } from '../stores/room'
|
||||||
|
import RoomCard from '../components/room/RoomCard.vue'
|
||||||
|
import Modal from '../components/common/Modal.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const roomStore = useRoomStore()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const newRoomName = ref('')
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await roomStore.fetchRooms()
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createRoom() {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const room = await roomStore.createRoom(newRoomName.value)
|
||||||
|
showCreateModal.value = false
|
||||||
|
newRoomName.value = ''
|
||||||
|
router.push(`/room/${room.id}`)
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToRoom(roomId) {
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
router.push(`/room/${roomId}`)
|
||||||
|
} else {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
frontend/src/views/LoginView.vue
Normal file
82
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card card">
|
||||||
|
<h2>Вход</h2>
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" v-model="email" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input type="password" v-model="password" required />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? 'Вход...' : 'Войти' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-link">
|
||||||
|
Нет аккаунта? <router-link to="/register">Зарегистрироваться</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.login(email.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.detail || 'Ошибка входа'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h2 {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
87
frontend/src/views/RegisterView.vue
Normal file
87
frontend/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card card">
|
||||||
|
<h2>Регистрация</h2>
|
||||||
|
<form @submit.prevent="handleRegister">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Имя пользователя</label>
|
||||||
|
<input type="text" v-model="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" v-model="email" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input type="password" v-model="password" required minlength="6" />
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
|
<button type="submit" class="btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? 'Регистрация...' : 'Зарегистрироваться' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="auth-link">
|
||||||
|
Уже есть аккаунт? <router-link to="/login">Войти</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.register(username.value, email.value, password.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.detail || 'Ошибка регистрации'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(100vh - 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h2 {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
frontend/src/views/RoomView.vue
Normal file
193
frontend/src/views/RoomView.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="room-page" v-if="room">
|
||||||
|
<div class="room-header">
|
||||||
|
<h1>{{ room.name }}</h1>
|
||||||
|
<button class="btn-secondary" @click="leaveAndGoHome">Выйти из комнаты</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-layout">
|
||||||
|
<div class="main-section">
|
||||||
|
<AudioPlayer
|
||||||
|
:ws="websocket"
|
||||||
|
@player-action="handlePlayerAction"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="queue-section card">
|
||||||
|
<div class="queue-header">
|
||||||
|
<h3>Очередь</h3>
|
||||||
|
<button class="btn-secondary" @click="showAddTrack = true">Добавить</button>
|
||||||
|
</div>
|
||||||
|
<Queue :queue="roomStore.queue" @play-track="playTrack" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-section">
|
||||||
|
<ParticipantsList :participants="roomStore.participants" />
|
||||||
|
<ChatWindow :room-id="roomId" :ws="websocket" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="showAddTrack = false">
|
||||||
|
<TrackList
|
||||||
|
:tracks="tracksStore.tracks"
|
||||||
|
selectable
|
||||||
|
@select="addTrackToQueue"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading">Загрузка...</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useRoomStore } from '../stores/room'
|
||||||
|
import { useTracksStore } from '../stores/tracks'
|
||||||
|
import { usePlayerStore } from '../stores/player'
|
||||||
|
import { useWebSocket } from '../composables/useWebSocket'
|
||||||
|
import { usePlayer } from '../composables/usePlayer'
|
||||||
|
import AudioPlayer from '../components/player/AudioPlayer.vue'
|
||||||
|
import Queue from '../components/room/Queue.vue'
|
||||||
|
import ParticipantsList from '../components/room/ParticipantsList.vue'
|
||||||
|
import ChatWindow from '../components/chat/ChatWindow.vue'
|
||||||
|
import TrackList from '../components/tracks/TrackList.vue'
|
||||||
|
import Modal from '../components/common/Modal.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const roomStore = useRoomStore()
|
||||||
|
const tracksStore = useTracksStore()
|
||||||
|
const playerStore = usePlayerStore()
|
||||||
|
|
||||||
|
const roomId = route.params.id
|
||||||
|
const room = ref(null)
|
||||||
|
const showAddTrack = ref(false)
|
||||||
|
|
||||||
|
const { syncToState, setOnTrackEnded } = usePlayer()
|
||||||
|
|
||||||
|
function handleTrackEnded() {
|
||||||
|
sendPlayerAction('next')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWsMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'player_state':
|
||||||
|
case 'sync_state':
|
||||||
|
// Call syncToState BEFORE updating store so it can detect URL changes
|
||||||
|
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':
|
||||||
|
roomStore.fetchQueue(roomId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connect, disconnect, sendPlayerAction, connected } = useWebSocket(roomId, handleWsMessage)
|
||||||
|
|
||||||
|
const websocket = { sendPlayerAction, connected }
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await roomStore.fetchRoom(roomId)
|
||||||
|
room.value = roomStore.currentRoom
|
||||||
|
|
||||||
|
await roomStore.joinRoom(roomId)
|
||||||
|
await roomStore.fetchQueue(roomId)
|
||||||
|
await tracksStore.fetchTracks()
|
||||||
|
|
||||||
|
// Set callback for when track ends
|
||||||
|
setOnTrackEnded(handleTrackEnded)
|
||||||
|
|
||||||
|
connect()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePlayerAction(action, position) {
|
||||||
|
sendPlayerAction(action, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTrack(track) {
|
||||||
|
sendPlayerAction('set_track', null, track.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTrackToQueue(track) {
|
||||||
|
await roomStore.addToQueue(roomId, track.id)
|
||||||
|
showAddTrack.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaveAndGoHome() {
|
||||||
|
await roomStore.leaveRoom(roomId)
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.room-page {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 350px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.room-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
frontend/src/views/TracksView.vue
Normal file
74
frontend/src/views/TracksView.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tracks-page">
|
||||||
|
<div class="header-section">
|
||||||
|
<h1>Библиотека треков</h1>
|
||||||
|
<button class="btn-primary" @click="showUpload = true">Загрузить трек</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tracksStore.loading" class="loading">Загрузка...</div>
|
||||||
|
|
||||||
|
<div v-else-if="tracksStore.tracks.length === 0" class="empty">
|
||||||
|
<p>Нет треков. Загрузите первый!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="tracks-list card">
|
||||||
|
<TrackList
|
||||||
|
:tracks="tracksStore.tracks"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal v-if="showUpload" title="Загрузить трек" @close="showUpload = false">
|
||||||
|
<UploadTrack @uploaded="showUpload = false" />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useTracksStore } from '../stores/tracks'
|
||||||
|
import TrackList from '../components/tracks/TrackList.vue'
|
||||||
|
import UploadTrack from '../components/tracks/UploadTrack.vue'
|
||||||
|
import Modal from '../components/common/Modal.vue'
|
||||||
|
|
||||||
|
const tracksStore = useTracksStore()
|
||||||
|
|
||||||
|
const showUpload = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
tracksStore.fetchTracks()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleDelete(track) {
|
||||||
|
if (confirm(`Удалить трек "${track.title}"?`)) {
|
||||||
|
await tracksStore.deleteTrack(track.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tracks-page {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks-list {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 4000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:4001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:4001',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user