diff --git a/Makefile b/Makefile index aeb80b7..198bda2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-backend dev-frontend install install-backend install-frontend build up down logs migrate +.PHONY: help dev dev-backend dev-frontend install install-backend install-frontend build build-backend build-frontend rebuild rebuild-backend rebuild-frontend rebuild-clean rebuild-clean-backend rebuild-clean-frontend up down logs migrate help: @echo "EnigFM - Команды:" @@ -8,7 +8,15 @@ help: @echo " make dev-backend - Запустить только backend" @echo " make dev-frontend - Запустить только frontend" @echo "" - @echo " make build - Собрать Docker образы" + @echo " make build - Собрать Docker образы (backend + frontend)" + @echo " make build-backend - Собрать только backend" + @echo " make build-frontend - Собрать только frontend" + @echo " make rebuild - Пересобрать и запустить всё" + @echo " make rebuild-backend - Пересобрать и перезапустить только backend" + @echo " make rebuild-frontend - Пересобрать и перезапустить только frontend" + @echo " make rebuild-clean - Пересобрать всё без кеша" + @echo " make rebuild-clean-backend - Пересобрать backend без кеша" + @echo " make rebuild-clean-frontend - Пересобрать frontend без кеша" @echo " make up - Запустить через Docker" @echo " make down - Остановить Docker" @echo " make logs - Показать логи Docker" @@ -16,6 +24,7 @@ help: @echo " make migrate - Создать миграцию БД" @echo " make migrate-up - Применить миграции" @echo " make migrate-down - Откатить миграцию" + @echo " make migrate-rebuild - Пересобрать контейнеры и применить миграции" # Установка зависимостей install: install-backend install-frontend @@ -41,6 +50,12 @@ dev-frontend: build: docker-compose build +build-backend: + docker-compose build backend + +build-frontend: + docker-compose build frontend + up: docker-compose up -d @@ -51,11 +66,35 @@ rebuild: docker-compose down docker-compose up -d --build +rebuild-backend: + docker-compose stop backend + docker-compose rm -f backend + docker-compose build backend + docker-compose up -d backend + +rebuild-frontend: + docker-compose stop frontend + docker-compose rm -f frontend + docker-compose build frontend + docker-compose up -d frontend + rebuild-clean: docker-compose down docker-compose build --no-cache docker-compose up -d +rebuild-clean-backend: + docker-compose stop backend + docker-compose rm -f backend + docker-compose build --no-cache backend + docker-compose up -d backend + +rebuild-clean-frontend: + docker-compose stop frontend + docker-compose rm -f frontend + docker-compose build --no-cache frontend + docker-compose up -d frontend + logs: docker-compose logs -f @@ -67,13 +106,20 @@ logs-frontend: # Миграции migrate: - cd backend && alembic revision --autogenerate -m "$(msg)" + docker-compose exec backend alembic revision --autogenerate -m "$(msg)" migrate-up: - cd backend && alembic upgrade head + docker-compose exec backend alembic upgrade head migrate-down: - cd backend && alembic downgrade -1 + docker-compose exec backend alembic downgrade -1 + +migrate-rebuild: + docker-compose down + docker-compose up -d --build + @echo "Waiting for containers to start..." + @sleep 5 + docker-compose exec backend alembic upgrade head # БД db-shell: diff --git a/backend/alembic/versions/003_remove_email.py b/backend/alembic/versions/003_remove_email.py new file mode 100644 index 0000000..82f0676 --- /dev/null +++ b/backend/alembic/versions/003_remove_email.py @@ -0,0 +1,30 @@ +"""Remove email from users + +Revision ID: 003 +Revises: 002 +Create Date: 2024-12-19 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '003' +down_revision: Union[str, None] = '002' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop unique constraint on email + op.drop_constraint('users_email_key', 'users', type_='unique') + # Drop email column + op.drop_column('users', 'email') + + +def downgrade() -> None: + # Add email column back + op.add_column('users', sa.Column('email', sa.String(255), nullable=True)) + # Add unique constraint back + op.create_unique_constraint('users_email_key', 'users', ['email']) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 6a61349..f9b38c6 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -11,7 +11,6 @@ class User(Base): 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) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0f38e2a..2e5571b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -12,14 +12,6 @@ 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(): @@ -31,7 +23,6 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): # Create user user = User( username=user_data.username, - email=user_data.email, password_hash=get_password_hash(user_data.password), ) db.add(user) @@ -44,13 +35,13 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)): @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)) + result = await db.execute(select(User).where(User.username == user_data.username)) 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", + detail="Invalid username or password", ) access_token = create_access_token(data={"sub": str(user.id)}) diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index 3a86d56..2033dbc 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -7,7 +7,7 @@ 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.room import RoomCreate, RoomResponse, RoomDetailResponse, QueueAdd, QueueAddMultiple from ..schemas.track import TrackResponse from ..schemas.user import UserResponse from ..services.auth import get_current_user @@ -197,6 +197,21 @@ async def add_to_queue( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + # Check if track already in queue + result = await db.execute( + select(RoomQueue).where( + RoomQueue.room_id == room_id, + RoomQueue.track_id == data.track_id, + ) + ) + existing_item = result.scalar_one_or_none() + + if existing_item: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Track already in queue", + ) + # Get max position result = await db.execute( select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id) @@ -221,6 +236,69 @@ async def add_to_queue( return {"status": "added"} +@router.post("/{room_id}/queue/bulk") +async def add_multiple_to_queue( + room_id: UUID, + data: QueueAddMultiple, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Add multiple tracks to queue at once, skipping duplicates.""" + if not data.track_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No tracks provided", + ) + + # Get existing tracks in queue + result = await db.execute( + select(RoomQueue.track_id).where(RoomQueue.room_id == room_id) + ) + existing_track_ids = set(result.scalars().all()) + + # Filter out duplicates + new_track_ids = [tid for tid in data.track_ids if tid not in existing_track_ids] + + if not new_track_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="All tracks already in queue", + ) + + # Get max position + result = await db.execute( + select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id) + ) + max_pos = result.scalar() or 0 + + # Add all new tracks + added_count = 0 + for i, track_id in enumerate(new_track_ids): + queue_item = RoomQueue( + room_id=room_id, + track_id=track_id, + position=max_pos + i + 1, + added_by=current_user.id, + ) + db.add(queue_item) + added_count += 1 + + await db.flush() + + # Notify others + await manager.broadcast_to_room( + room_id, + {"type": "queue_updated"}, + ) + + skipped_count = len(data.track_ids) - added_count + return { + "status": "added", + "added": added_count, + "skipped": skipped_count, + } + + @router.delete("/{room_id}/queue/{track_id}") async def remove_from_queue( room_id: UUID, diff --git a/backend/app/routers/tracks.py b/backend/app/routers/tracks.py index b872529..e8e1478 100644 --- a/backend/app/routers/tracks.py +++ b/backend/app/routers/tracks.py @@ -32,6 +32,74 @@ async def get_tracks( return result.scalars().all() +async def _process_single_track( + file: UploadFile, + title: str, + artist: str, + current_user: User, +) -> tuple[Track, Exception | None]: + """Process a single track upload. Returns (track, error).""" + try: + # Check file type + if not file.content_type or not file.content_type.startswith("audio/"): + return None, Exception("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: + return None, Exception(f"File size exceeds {settings.max_file_size_mb}MB limit") + + # Check storage limit + if not await can_upload_file(file_size): + return None, Exception("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 as e: + return None, Exception("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, + ) + return track, None + + except Exception as e: + return None, e + + @router.post("/upload", response_model=TrackResponse) async def upload_track( file: UploadFile = File(...), @@ -40,78 +108,46 @@ async def upload_track( 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/"): + track, error = await _process_single_track(file, title, artist, current_user) + + if error: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="File must be an audio file", + detail=str(error), ) - # 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.post("/upload-multiple", response_model=list[TrackResponse]) +async def upload_multiple_tracks( + files: list[UploadFile] = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload multiple tracks at once. Each file's metadata is auto-detected from ID3 tags.""" + if not files: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No files provided", + ) + + # Process all files + results = [] + for file in files: + track, error = await _process_single_track(file, None, None, current_user) + if track: + db.add(track) + results.append(track) + + # Commit all at once + await db.flush() + + return results + + @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)) diff --git a/backend/app/schemas/room.py b/backend/app/schemas/room.py index 892dff0..069b49e 100644 --- a/backend/app/schemas/room.py +++ b/backend/app/schemas/room.py @@ -45,3 +45,7 @@ class PlayerAction(BaseModel): class QueueAdd(BaseModel): track_id: UUID + + +class QueueAddMultiple(BaseModel): + track_ids: list[UUID] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 93770fd..5d046ff 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,23 +1,21 @@ -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel from uuid import UUID from datetime import datetime class UserCreate(BaseModel): username: str - email: EmailStr password: str class UserLogin(BaseModel): - email: EmailStr + username: str password: str class UserResponse(BaseModel): id: UUID username: str - email: str created_at: datetime class Config: diff --git a/frontend/src/components/tracks/TrackItem.vue b/frontend/src/components/tracks/TrackItem.vue index 5238244..596002e 100644 --- a/frontend/src/components/tracks/TrackItem.vue +++ b/frontend/src/components/tracks/TrackItem.vue @@ -1,19 +1,27 @@ - + + {{ track.title }} {{ track.artist }} {{ formatDuration(track.duration) }} + В очереди + @@ -23,7 +31,7 @@ diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index c981ed9..7f52051 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -8,15 +8,15 @@ export const useAuthStore = defineStore('auth', () => { const isAuthenticated = computed(() => !!token.value) - async function login(email, password) { - const response = await api.post('/api/auth/login', { email, password }) + async function login(username, password) { + const response = await api.post('/api/auth/login', { username, 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 }) + async function register(username, password) { + const response = await api.post('/api/auth/register', { username, password }) token.value = response.data.access_token localStorage.setItem('token', token.value) await fetchUser() diff --git a/frontend/src/stores/room.js b/frontend/src/stores/room.js index 724a631..a195241 100644 --- a/frontend/src/stores/room.js +++ b/frontend/src/stores/room.js @@ -46,6 +46,11 @@ export const useRoomStore = defineStore('room', () => { await api.post(`/api/rooms/${roomId}/queue`, { track_id: trackId }) } + async function addMultipleToQueue(roomId, trackIds) { + const response = await api.post(`/api/rooms/${roomId}/queue/bulk`, { track_ids: trackIds }) + return response.data + } + async function removeFromQueue(roomId, trackId) { await api.delete(`/api/rooms/${roomId}/queue/${trackId}`) } @@ -77,6 +82,7 @@ export const useRoomStore = defineStore('room', () => { leaveRoom, fetchQueue, addToQueue, + addMultipleToQueue, removeFromQueue, updateParticipants, addParticipant, diff --git a/frontend/src/stores/tracks.js b/frontend/src/stores/tracks.js index d3c5485..1178a5a 100644 --- a/frontend/src/stores/tracks.js +++ b/frontend/src/stores/tracks.js @@ -29,6 +29,29 @@ export const useTracksStore = defineStore('tracks', () => { return response.data } + async function uploadMultipleTracks(files, onProgress) { + const formData = new FormData() + files.forEach(file => { + formData.append('files', file) + }) + + const response = await api.post('/api/tracks/upload-multiple', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (progressEvent) => { + if (onProgress) { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total) + onProgress(percentCompleted) + } + } + }) + + // Add all uploaded tracks to the beginning + response.data.forEach(track => { + tracks.value.unshift(track) + }) + return response.data + } + async function deleteTrack(trackId) { await api.delete(`/api/tracks/${trackId}`) tracks.value = tracks.value.filter(t => t.id !== trackId) @@ -44,6 +67,7 @@ export const useTracksStore = defineStore('tracks', () => { loading, fetchTracks, uploadTrack, + uploadMultipleTracks, deleteTrack, getTrackUrl, } diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index ef156b0..136be0b 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -4,8 +4,8 @@ Вход - Email - + Имя пользователя + Пароль @@ -31,7 +31,7 @@ import { useAuthStore } from '../stores/auth' const router = useRouter() const authStore = useAuthStore() -const email = ref('') +const username = ref('') const password = ref('') const error = ref('') const loading = ref(false) @@ -41,7 +41,7 @@ async function handleLogin() { loading.value = true try { - await authStore.login(email.value, password.value) + await authStore.login(username.value, password.value) router.push('/') } catch (e) { error.value = e.response?.data?.detail || 'Ошибка входа' diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue index 73d869c..9b36a8c 100644 --- a/frontend/src/views/RegisterView.vue +++ b/frontend/src/views/RegisterView.vue @@ -7,10 +7,6 @@ Имя пользователя - - Email - - Пароль @@ -36,7 +32,6 @@ const router = useRouter() const authStore = useAuthStore() const username = ref('') -const email = ref('') const password = ref('') const error = ref('') const loading = ref(false) @@ -46,7 +41,7 @@ async function handleRegister() { loading.value = true try { - await authStore.register(username.value, email.value, password.value) + await authStore.register(username.value, password.value) router.push('/') } catch (e) { error.value = e.response?.data?.detail || 'Ошибка регистрации' diff --git a/frontend/src/views/RoomView.vue b/frontend/src/views/RoomView.vue index 8784d86..1a6b3fc 100644 --- a/frontend/src/views/RoomView.vue +++ b/frontend/src/views/RoomView.vue @@ -21,11 +21,64 @@ - + + + + + + + + + + Мои треки + + + + Не добавленные в комнату + + + + + + + Найдено: {{ filteredTracks.length }} + + Выбрано: {{ selectedTracks.length }} + + + Очистить + + + + Добавить выбранные ({{ selectedTracks.length }}) + + + {{ addTrackError }} + {{ addTrackSuccess }} @@ -33,11 +86,12 @@
{{ addTrackError }}
{{ addTrackSuccess }}