This commit is contained in:
2025-12-12 13:30:09 +03:00
commit 2f1e1f35e3
75 changed files with 4603 additions and 0 deletions

9
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View 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"}

View 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')

View 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
View File

32
backend/app/config.py Normal file
View 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
View 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
View 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"}

View 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"]

View 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")

View 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")

View 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")

View 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")

View File

View 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

View 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)
]

View 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"}

View 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}",
}
)

View 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(),
})

View File

View 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

View 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

View 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

View 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

View File

View 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

View 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()

View 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()

View File

View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:4001
VITE_WS_URL=ws://localhost:4001

20
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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>

View 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;
}

View 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>

View 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>

View 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>

View 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')">&times;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View 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,
}
}

View 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
View 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')

View 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

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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>

View 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>

View 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>

View 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>

View 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
View 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,
},
},
},
})