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