commit 2f1e1f35e38bb52f3d5158c02ec174930d4587c6 Author: mamonov.ep Date: Fri Dec 12 13:30:09 2025 +0300 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5a6fca9 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99865b6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aeb80b7 --- /dev/null +++ b/Makefile @@ -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 diff --git a/TECHNICAL_SPEC.md b/TECHNICAL_SPEC.md new file mode 100644 index 0000000..0980ff7 --- /dev/null +++ b/TECHNICAL_SPEC.md @@ -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. Принятые решения + +- **Аутентификация** — обязательная регистрация +- **Права управления** — все участники могут управлять плеером +- **Чат** — текстовый чат в каждой комнате +- **Библиотека музыки** — общая для всех пользователей +- **Приватность** — все комнаты публичные diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..847e307 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0233051 --- /dev/null +++ b/backend/Dockerfile @@ -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 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..e18d2cf --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..2d49626 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..56a18a9 --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -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') diff --git a/backend/alembic/versions/002_add_playback_started_at.py b/backend/alembic/versions/002_add_playback_started_at.py new file mode 100644 index 0000000..88437ff --- /dev/null +++ b/backend/alembic/versions/002_add_playback_started_at.py @@ -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') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..a635b04 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..dc47ba5 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2cbed54 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..001be5b --- /dev/null +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..b749e33 --- /dev/null +++ b/backend/app/models/message.py @@ -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") diff --git a/backend/app/models/room.py b/backend/app/models/room.py new file mode 100644 index 0000000..b010876 --- /dev/null +++ b/backend/app/models/room.py @@ -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") diff --git a/backend/app/models/track.py b/backend/app/models/track.py new file mode 100644 index 0000000..4071e75 --- /dev/null +++ b/backend/app/models/track.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..6a61349 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..0f38e2a --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/messages.py b/backend/app/routers/messages.py new file mode 100644 index 0000000..37ea16f --- /dev/null +++ b/backend/app/routers/messages.py @@ -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) + ] diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py new file mode 100644 index 0000000..3a86d56 --- /dev/null +++ b/backend/app/routers/rooms.py @@ -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"} diff --git a/backend/app/routers/tracks.py b/backend/app/routers/tracks.py new file mode 100644 index 0000000..d49586b --- /dev/null +++ b/backend/app/routers/tracks.py @@ -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}", + } + ) diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py new file mode 100644 index 0000000..ee939c5 --- /dev/null +++ b/backend/app/routers/websocket.py @@ -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(), + }) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py new file mode 100644 index 0000000..1ce9ce6 --- /dev/null +++ b/backend/app/schemas/message.py @@ -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 diff --git a/backend/app/schemas/room.py b/backend/app/schemas/room.py new file mode 100644 index 0000000..892dff0 --- /dev/null +++ b/backend/app/schemas/room.py @@ -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 diff --git a/backend/app/schemas/track.py b/backend/app/schemas/track.py new file mode 100644 index 0000000..1174ddf --- /dev/null +++ b/backend/app/schemas/track.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..93770fd --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..0ef3197 --- /dev/null +++ b/backend/app/services/auth.py @@ -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 diff --git a/backend/app/services/s3.py b/backend/app/services/s3.py new file mode 100644 index 0000000..7b90cb3 --- /dev/null +++ b/backend/app/services/s3.py @@ -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() diff --git a/backend/app/services/sync.py b/backend/app/services/sync.py new file mode 100644 index 0000000..7e6d20a --- /dev/null +++ b/backend/app/services/sync.py @@ -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() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py new file mode 100644 index 0000000..a69db5d --- /dev/null +++ b/backend/app/utils/security.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..279f930 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7f1dc6e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..ac23f0c --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:4001 +VITE_WS_URL=ws://localhost:4001 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9d435a8 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..51f53ce --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + EnigFM - Слушай музыку вместе + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f19549d --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8fa2f58 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..99721d6 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/frontend/src/assets/styles/main.css b/frontend/src/assets/styles/main.css new file mode 100644 index 0000000..5585424 --- /dev/null +++ b/frontend/src/assets/styles/main.css @@ -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; +} diff --git a/frontend/src/components/chat/ChatMessage.vue b/frontend/src/components/chat/ChatMessage.vue new file mode 100644 index 0000000..496040e --- /dev/null +++ b/frontend/src/components/chat/ChatMessage.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/frontend/src/components/chat/ChatWindow.vue b/frontend/src/components/chat/ChatWindow.vue new file mode 100644 index 0000000..ae9391f --- /dev/null +++ b/frontend/src/components/chat/ChatWindow.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/frontend/src/components/common/Header.vue b/frontend/src/components/common/Header.vue new file mode 100644 index 0000000..69e0c29 --- /dev/null +++ b/frontend/src/components/common/Header.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/frontend/src/components/common/Modal.vue b/frontend/src/components/common/Modal.vue new file mode 100644 index 0000000..97e13b1 --- /dev/null +++ b/frontend/src/components/common/Modal.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/player/AudioPlayer.vue b/frontend/src/components/player/AudioPlayer.vue new file mode 100644 index 0000000..4478795 --- /dev/null +++ b/frontend/src/components/player/AudioPlayer.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/components/player/PlayerControls.vue b/frontend/src/components/player/PlayerControls.vue new file mode 100644 index 0000000..e225cb5 --- /dev/null +++ b/frontend/src/components/player/PlayerControls.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/player/ProgressBar.vue b/frontend/src/components/player/ProgressBar.vue new file mode 100644 index 0000000..7215fe3 --- /dev/null +++ b/frontend/src/components/player/ProgressBar.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/components/player/VolumeControl.vue b/frontend/src/components/player/VolumeControl.vue new file mode 100644 index 0000000..a190525 --- /dev/null +++ b/frontend/src/components/player/VolumeControl.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/frontend/src/components/room/ParticipantsList.vue b/frontend/src/components/room/ParticipantsList.vue new file mode 100644 index 0000000..3d98bd1 --- /dev/null +++ b/frontend/src/components/room/ParticipantsList.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/components/room/Queue.vue b/frontend/src/components/room/Queue.vue new file mode 100644 index 0000000..8310634 --- /dev/null +++ b/frontend/src/components/room/Queue.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/frontend/src/components/room/RoomCard.vue b/frontend/src/components/room/RoomCard.vue new file mode 100644 index 0000000..d2056f2 --- /dev/null +++ b/frontend/src/components/room/RoomCard.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/components/tracks/TrackItem.vue b/frontend/src/components/tracks/TrackItem.vue new file mode 100644 index 0000000..5238244 --- /dev/null +++ b/frontend/src/components/tracks/TrackItem.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/frontend/src/components/tracks/TrackList.vue b/frontend/src/components/tracks/TrackList.vue new file mode 100644 index 0000000..6ac1c8a --- /dev/null +++ b/frontend/src/components/tracks/TrackList.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/components/tracks/UploadTrack.vue b/frontend/src/components/tracks/UploadTrack.vue new file mode 100644 index 0000000..c15ee61 --- /dev/null +++ b/frontend/src/components/tracks/UploadTrack.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js new file mode 100644 index 0000000..48f9aed --- /dev/null +++ b/frontend/src/composables/useApi.js @@ -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 diff --git a/frontend/src/composables/usePlayer.js b/frontend/src/composables/usePlayer.js new file mode 100644 index 0000000..d1003af --- /dev/null +++ b/frontend/src/composables/usePlayer.js @@ -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, + } +} diff --git a/frontend/src/composables/useWebSocket.js b/frontend/src/composables/useWebSocket.js new file mode 100644 index 0000000..3bc71de --- /dev/null +++ b/frontend/src/composables/useWebSocket.js @@ -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, + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..2cfd6b7 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..ae2b8d8 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..c981ed9 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -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, + } +}) diff --git a/frontend/src/stores/player.js b/frontend/src/stores/player.js new file mode 100644 index 0000000..d188eaa --- /dev/null +++ b/frontend/src/stores/player.js @@ -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, + } +}) diff --git a/frontend/src/stores/room.js b/frontend/src/stores/room.js new file mode 100644 index 0000000..724a631 --- /dev/null +++ b/frontend/src/stores/room.js @@ -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, + } +}) diff --git a/frontend/src/stores/tracks.js b/frontend/src/stores/tracks.js new file mode 100644 index 0000000..1303982 --- /dev/null +++ b/frontend/src/stores/tracks.js @@ -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, + } +}) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue new file mode 100644 index 0000000..def6595 --- /dev/null +++ b/frontend/src/views/HomeView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue new file mode 100644 index 0000000..ef156b0 --- /dev/null +++ b/frontend/src/views/LoginView.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue new file mode 100644 index 0000000..73d869c --- /dev/null +++ b/frontend/src/views/RegisterView.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/frontend/src/views/RoomView.vue b/frontend/src/views/RoomView.vue new file mode 100644 index 0000000..0f1551e --- /dev/null +++ b/frontend/src/views/RoomView.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/frontend/src/views/TracksView.vue b/frontend/src/views/TracksView.vue new file mode 100644 index 0000000..c9a6cd6 --- /dev/null +++ b/frontend/src/views/TracksView.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..71bdf7e --- /dev/null +++ b/frontend/vite.config.js @@ -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, + }, + }, + }, +})