Add multiple features: auth, uploads, queue management, and filters
- Replace email with username for authentication - Update User model, schemas, and auth endpoints - Update frontend login and register views - Add migration to remove email column - Add multiple track upload support - New backend endpoint for bulk upload - Frontend multi-file selection with progress - Auto-extract metadata from ID3 tags - Visual upload progress for each file - Prevent duplicate tracks in room queue - Backend validation for duplicates - Visual indication of tracks already in queue - Error handling with user feedback - Add bulk track selection for rooms - Multi-select mode with checkboxes - Bulk add endpoint with duplicate filtering - Selection counter and controls - Add track filters in room modal - Search by title and artist - Filter by "My tracks" - Filter by "Not in queue" - Live filtering with result counter - Improve Makefile - Add build-backend and build-frontend commands - Add rebuild-backend and rebuild-frontend commands - Add rebuild-clean variants - Update migrations to run in Docker 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
56
Makefile
56
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:
|
help:
|
||||||
@echo "EnigFM - Команды:"
|
@echo "EnigFM - Команды:"
|
||||||
@@ -8,7 +8,15 @@ help:
|
|||||||
@echo " make dev-backend - Запустить только backend"
|
@echo " make dev-backend - Запустить только backend"
|
||||||
@echo " make dev-frontend - Запустить только frontend"
|
@echo " make dev-frontend - Запустить только frontend"
|
||||||
@echo ""
|
@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 up - Запустить через Docker"
|
||||||
@echo " make down - Остановить Docker"
|
@echo " make down - Остановить Docker"
|
||||||
@echo " make logs - Показать логи Docker"
|
@echo " make logs - Показать логи Docker"
|
||||||
@@ -16,6 +24,7 @@ help:
|
|||||||
@echo " make migrate - Создать миграцию БД"
|
@echo " make migrate - Создать миграцию БД"
|
||||||
@echo " make migrate-up - Применить миграции"
|
@echo " make migrate-up - Применить миграции"
|
||||||
@echo " make migrate-down - Откатить миграцию"
|
@echo " make migrate-down - Откатить миграцию"
|
||||||
|
@echo " make migrate-rebuild - Пересобрать контейнеры и применить миграции"
|
||||||
|
|
||||||
# Установка зависимостей
|
# Установка зависимостей
|
||||||
install: install-backend install-frontend
|
install: install-backend install-frontend
|
||||||
@@ -41,6 +50,12 @@ dev-frontend:
|
|||||||
build:
|
build:
|
||||||
docker-compose build
|
docker-compose build
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
docker-compose build backend
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
docker-compose build frontend
|
||||||
|
|
||||||
up:
|
up:
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
@@ -51,11 +66,35 @@ rebuild:
|
|||||||
docker-compose down
|
docker-compose down
|
||||||
docker-compose up -d --build
|
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:
|
rebuild-clean:
|
||||||
docker-compose down
|
docker-compose down
|
||||||
docker-compose build --no-cache
|
docker-compose build --no-cache
|
||||||
docker-compose up -d
|
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:
|
logs:
|
||||||
docker-compose logs -f
|
docker-compose logs -f
|
||||||
|
|
||||||
@@ -67,13 +106,20 @@ logs-frontend:
|
|||||||
|
|
||||||
# Миграции
|
# Миграции
|
||||||
migrate:
|
migrate:
|
||||||
cd backend && alembic revision --autogenerate -m "$(msg)"
|
docker-compose exec backend alembic revision --autogenerate -m "$(msg)"
|
||||||
|
|
||||||
migrate-up:
|
migrate-up:
|
||||||
cd backend && alembic upgrade head
|
docker-compose exec backend alembic upgrade head
|
||||||
|
|
||||||
migrate-down:
|
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:
|
db-shell:
|
||||||
|
|||||||
30
backend/alembic/versions/003_remove_email.py
Normal file
30
backend/alembic/versions/003_remove_email.py
Normal file
@@ -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'])
|
||||||
@@ -11,7 +11,6 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
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)
|
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)
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|||||||
|
|
||||||
@router.post("/register", response_model=Token)
|
@router.post("/register", response_model=Token)
|
||||||
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
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
|
# Check if username exists
|
||||||
result = await db.execute(select(User).where(User.username == user_data.username))
|
result = await db.execute(select(User).where(User.username == user_data.username))
|
||||||
if result.scalar_one_or_none():
|
if result.scalar_one_or_none():
|
||||||
@@ -31,7 +23,6 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
# Create user
|
# Create user
|
||||||
user = User(
|
user = User(
|
||||||
username=user_data.username,
|
username=user_data.username,
|
||||||
email=user_data.email,
|
|
||||||
password_hash=get_password_hash(user_data.password),
|
password_hash=get_password_hash(user_data.password),
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -44,13 +35,13 @@ async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
|
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()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user or not verify_password(user_data.password, user.password_hash):
|
if not user or not verify_password(user_data.password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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)})
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from ..database import get_db
|
|||||||
from ..models.user import User
|
from ..models.user import User
|
||||||
from ..models.room import Room, RoomParticipant
|
from ..models.room import Room, RoomParticipant
|
||||||
from ..models.track import RoomQueue
|
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.track import TrackResponse
|
||||||
from ..schemas.user import UserResponse
|
from ..schemas.user import UserResponse
|
||||||
from ..services.auth import get_current_user
|
from ..services.auth import get_current_user
|
||||||
@@ -197,6 +197,21 @@ async def add_to_queue(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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
|
# Get max position
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id)
|
select(func.max(RoomQueue.position)).where(RoomQueue.room_id == room_id)
|
||||||
@@ -221,6 +236,69 @@ async def add_to_queue(
|
|||||||
return {"status": "added"}
|
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}")
|
@router.delete("/{room_id}/queue/{track_id}")
|
||||||
async def remove_from_queue(
|
async def remove_from_queue(
|
||||||
room_id: UUID,
|
room_id: UUID,
|
||||||
|
|||||||
@@ -32,6 +32,74 @@ async def get_tracks(
|
|||||||
return result.scalars().all()
|
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)
|
@router.post("/upload", response_model=TrackResponse)
|
||||||
async def upload_track(
|
async def upload_track(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -40,78 +108,46 @@ async def upload_track(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
# Check file type
|
track, error = await _process_single_track(file, title, artist, current_user)
|
||||||
if not file.content_type or not file.content_type.startswith("audio/"):
|
|
||||||
|
if error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
db.add(track)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
return track
|
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)
|
@router.get("/{track_id}", response_model=TrackWithUrl)
|
||||||
async def get_track(track_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def get_track(track_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(Track).where(Track.id == track_id))
|
result = await db.execute(select(Track).where(Track.id == track_id))
|
||||||
|
|||||||
@@ -45,3 +45,7 @@ class PlayerAction(BaseModel):
|
|||||||
|
|
||||||
class QueueAdd(BaseModel):
|
class QueueAdd(BaseModel):
|
||||||
track_id: UUID
|
track_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class QueueAddMultiple(BaseModel):
|
||||||
|
track_ids: list[UUID]
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
email: EmailStr
|
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
class UserLogin(BaseModel):
|
||||||
email: EmailStr
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
username: str
|
username: str
|
||||||
email: str
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="track-item" @click="selectable && $emit('select')">
|
<div class="track-item" :class="{ 'in-queue': inQueue, 'selected': isSelected }" @click="handleClick">
|
||||||
|
<input
|
||||||
|
v-if="multiSelect && !inQueue"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isSelected"
|
||||||
|
@click.stop="$emit('toggle-select')"
|
||||||
|
class="track-checkbox"
|
||||||
|
/>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<span class="track-title">{{ track.title }}</span>
|
<span class="track-title">{{ track.title }}</span>
|
||||||
<span class="track-artist">{{ track.artist }}</span>
|
<span class="track-artist">{{ track.artist }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
|
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
|
||||||
|
<span v-if="inQueue" class="in-queue-label">В очереди</span>
|
||||||
<button
|
<button
|
||||||
v-if="selectable"
|
v-if="selectable && !multiSelect && !inQueue"
|
||||||
class="btn-primary add-btn"
|
class="btn-primary add-btn"
|
||||||
@click.stop="$emit('select')"
|
@click.stop="$emit('select')"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!selectable"
|
v-if="!selectable && !multiSelect"
|
||||||
class="btn-danger delete-btn"
|
class="btn-danger delete-btn"
|
||||||
@click.stop="$emit('delete')"
|
@click.stop="$emit('delete')"
|
||||||
>
|
>
|
||||||
@@ -23,7 +31,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
track: {
|
track: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
@@ -31,10 +39,30 @@ defineProps({
|
|||||||
selectable: {
|
selectable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
multiSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
inQueue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['select', 'delete'])
|
const emit = defineEmits(['select', 'toggle-select', 'delete'])
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (props.multiSelect && !props.inQueue) {
|
||||||
|
emit('toggle-select')
|
||||||
|
} else if (props.selectable && !props.inQueue) {
|
||||||
|
emit('select')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(ms) {
|
function formatDuration(ms) {
|
||||||
const seconds = Math.floor(ms / 1000)
|
const seconds = Math.floor(ms / 1000)
|
||||||
@@ -60,6 +88,16 @@ function formatDuration(ms) {
|
|||||||
background: #2d2d44;
|
background: #2d2d44;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-item.in-queue {
|
||||||
|
background: #2a2a3e;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.in-queue:hover {
|
||||||
|
background: #2a2a3e;
|
||||||
|
}
|
||||||
|
|
||||||
.track-info {
|
.track-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -100,4 +138,29 @@ function formatDuration(ms) {
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.in-queue-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.selected {
|
||||||
|
background: #2d4a2d;
|
||||||
|
border: 1px solid var(--color-primary, #1db954);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.selected:hover {
|
||||||
|
background: #355535;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
:key="track.id"
|
:key="track.id"
|
||||||
:track="track"
|
:track="track"
|
||||||
:selectable="selectable"
|
:selectable="selectable"
|
||||||
|
:multi-select="multiSelect"
|
||||||
|
:is-selected="selectedTrackIds.includes(track.id)"
|
||||||
|
:in-queue="queueTrackIds.includes(track.id)"
|
||||||
@select="$emit('select', track)"
|
@select="$emit('select', track)"
|
||||||
|
@toggle-select="$emit('toggle-select', track.id)"
|
||||||
@delete="$emit('delete', track)"
|
@delete="$emit('delete', track)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,10 +29,22 @@ defineProps({
|
|||||||
selectable: {
|
selectable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
multiSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
queueTrackIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedTrackIds: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['select', 'delete'])
|
defineEmits(['select', 'toggle-select', 'delete'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<form @submit.prevent="handleUpload" class="upload-form">
|
<form @submit.prevent="handleUpload" class="upload-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>MP3 файл (макс. {{ maxFileSizeMb }}MB)</label>
|
<label>MP3 файлы (макс. {{ maxFileSizeMb }}MB каждый)</label>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/mpeg,audio/mp3"
|
accept="audio/mpeg,audio/mp3"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
required
|
multiple
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
/>
|
/>
|
||||||
<small class="hint">Название и исполнитель будут взяты из тегов файла</small>
|
<small class="hint">Можно выбрать несколько файлов. Название и исполнитель будут взяты из тегов</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Название <span class="optional">(необязательно)</span></label>
|
<div v-if="files.length > 0" class="files-list">
|
||||||
<input type="text" v-model="title" placeholder="Оставьте пустым для автоопределения" />
|
<div class="files-header">
|
||||||
|
<span>Выбрано файлов: {{ files.length }}</span>
|
||||||
|
<button type="button" @click="clearFiles" class="btn-text">Очистить</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-item" v-for="(file, index) in files" :key="index">
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name">{{ file.name }}</span>
|
||||||
|
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="removeFile(index)" class="btn-remove">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Исполнитель <span class="optional">(необязательно)</span></label>
|
<div v-if="uploading" class="upload-progress">
|
||||||
<input type="text" v-model="artist" placeholder="Оставьте пустым для автоопределения" />
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: uploadProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">{{ uploadProgress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error-message">{{ error }}</p>
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
<button type="submit" class="btn-primary" :disabled="uploading">
|
<p v-if="successMessage" class="success-message">{{ successMessage }}</p>
|
||||||
{{ uploading ? 'Загрузка...' : 'Загрузить' }}
|
|
||||||
|
<button type="submit" class="btn-primary" :disabled="uploading || files.length === 0">
|
||||||
|
{{ uploading ? 'Загрузка...' : `Загрузить (${files.length})` }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
@@ -37,46 +53,78 @@ const tracksStore = useTracksStore()
|
|||||||
const maxFileSizeMb = import.meta.env.VITE_MAX_FILE_SIZE_MB || 10
|
const maxFileSizeMb = import.meta.env.VITE_MAX_FILE_SIZE_MB || 10
|
||||||
const maxFileSize = maxFileSizeMb * 1024 * 1024
|
const maxFileSize = maxFileSizeMb * 1024 * 1024
|
||||||
|
|
||||||
const title = ref('')
|
const files = ref([])
|
||||||
const artist = ref('')
|
|
||||||
const file = ref(null)
|
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
function handleFileSelect(e) {
|
function handleFileSelect(e) {
|
||||||
const selectedFile = e.target.files[0]
|
const selectedFiles = Array.from(e.target.files)
|
||||||
if (!selectedFile) return
|
if (selectedFiles.length === 0) return
|
||||||
|
|
||||||
// Check file size
|
error.value = ''
|
||||||
if (selectedFile.size > maxFileSize) {
|
successMessage.value = ''
|
||||||
error.value = `Файл слишком большой (макс. ${maxFileSizeMb}MB)`
|
|
||||||
fileInput.value.value = ''
|
// Check file sizes
|
||||||
|
const invalidFiles = selectedFiles.filter(f => f.size > maxFileSize)
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
error.value = `${invalidFiles.length} файл(ов) слишком большие (макс. ${maxFileSizeMb}MB каждый)`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file.value = selectedFile
|
files.value = selectedFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index) {
|
||||||
|
files.value = files.value.filter((_, i) => i !== index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
files.value = []
|
||||||
|
fileInput.value.value = ''
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload() {
|
async function handleUpload() {
|
||||||
if (!file.value) {
|
if (files.value.length === 0) {
|
||||||
error.value = 'Выберите файл'
|
error.value = 'Выберите файлы'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
uploadProgress.value = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tracksStore.uploadTrack(file.value, title.value, artist.value)
|
const uploadedTracks = await tracksStore.uploadMultipleTracks(
|
||||||
title.value = ''
|
files.value,
|
||||||
artist.value = ''
|
(progress) => {
|
||||||
file.value = null
|
uploadProgress.value = progress
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
successMessage.value = `Успешно загружено ${uploadedTracks.length} трек(ов)!`
|
||||||
|
files.value = []
|
||||||
fileInput.value.value = ''
|
fileInput.value.value = ''
|
||||||
emit('uploaded')
|
uploadProgress.value = 100
|
||||||
|
|
||||||
|
// Emit event after a short delay to show success message
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('uploaded')
|
||||||
|
}, 1500)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data?.detail || 'Ошибка загрузки'
|
error.value = e.response?.data?.detail || 'Ошибка загрузки'
|
||||||
|
uploadProgress.value = 0
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
@@ -101,8 +149,124 @@ async function handleUpload() {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.optional {
|
.files-list {
|
||||||
color: #666;
|
background: #1a1a1a;
|
||||||
font-weight: normal;
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary, #1db954);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: #252525;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: #ff4444;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-left: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background: #ff6666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary, #1db954);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
async function login(email, password) {
|
async function login(username, password) {
|
||||||
const response = await api.post('/api/auth/login', { email, password })
|
const response = await api.post('/api/auth/login', { username, password })
|
||||||
token.value = response.data.access_token
|
token.value = response.data.access_token
|
||||||
localStorage.setItem('token', token.value)
|
localStorage.setItem('token', token.value)
|
||||||
await fetchUser()
|
await fetchUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(username, email, password) {
|
async function register(username, password) {
|
||||||
const response = await api.post('/api/auth/register', { username, email, password })
|
const response = await api.post('/api/auth/register', { username, password })
|
||||||
token.value = response.data.access_token
|
token.value = response.data.access_token
|
||||||
localStorage.setItem('token', token.value)
|
localStorage.setItem('token', token.value)
|
||||||
await fetchUser()
|
await fetchUser()
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
await api.post(`/api/rooms/${roomId}/queue`, { track_id: trackId })
|
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) {
|
async function removeFromQueue(roomId, trackId) {
|
||||||
await api.delete(`/api/rooms/${roomId}/queue/${trackId}`)
|
await api.delete(`/api/rooms/${roomId}/queue/${trackId}`)
|
||||||
}
|
}
|
||||||
@@ -77,6 +82,7 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
leaveRoom,
|
leaveRoom,
|
||||||
fetchQueue,
|
fetchQueue,
|
||||||
addToQueue,
|
addToQueue,
|
||||||
|
addMultipleToQueue,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
updateParticipants,
|
updateParticipants,
|
||||||
addParticipant,
|
addParticipant,
|
||||||
|
|||||||
@@ -29,6 +29,29 @@ export const useTracksStore = defineStore('tracks', () => {
|
|||||||
return response.data
|
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) {
|
async function deleteTrack(trackId) {
|
||||||
await api.delete(`/api/tracks/${trackId}`)
|
await api.delete(`/api/tracks/${trackId}`)
|
||||||
tracks.value = tracks.value.filter(t => t.id !== trackId)
|
tracks.value = tracks.value.filter(t => t.id !== trackId)
|
||||||
@@ -44,6 +67,7 @@ export const useTracksStore = defineStore('tracks', () => {
|
|||||||
loading,
|
loading,
|
||||||
fetchTracks,
|
fetchTracks,
|
||||||
uploadTrack,
|
uploadTrack,
|
||||||
|
uploadMultipleTracks,
|
||||||
deleteTrack,
|
deleteTrack,
|
||||||
getTrackUrl,
|
getTrackUrl,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<h2>Вход</h2>
|
<h2>Вход</h2>
|
||||||
<form @submit.prevent="handleLogin">
|
<form @submit.prevent="handleLogin">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Email</label>
|
<label>Имя пользователя</label>
|
||||||
<input type="email" v-model="email" required />
|
<input type="text" v-model="username" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Пароль</label>
|
<label>Пароль</label>
|
||||||
@@ -31,7 +31,7 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const email = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -41,7 +41,7 @@ async function handleLogin() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authStore.login(email.value, password.value)
|
await authStore.login(username.value, password.value)
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data?.detail || 'Ошибка входа'
|
error.value = e.response?.data?.detail || 'Ошибка входа'
|
||||||
|
|||||||
@@ -7,10 +7,6 @@
|
|||||||
<label>Имя пользователя</label>
|
<label>Имя пользователя</label>
|
||||||
<input type="text" v-model="username" required />
|
<input type="text" v-model="username" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label>Email</label>
|
|
||||||
<input type="email" v-model="email" required />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Пароль</label>
|
<label>Пароль</label>
|
||||||
<input type="password" v-model="password" required minlength="6" />
|
<input type="password" v-model="password" required minlength="6" />
|
||||||
@@ -36,7 +32,6 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const email = ref('')
|
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -46,7 +41,7 @@ async function handleRegister() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authStore.register(username.value, email.value, password.value)
|
await authStore.register(username.value, password.value)
|
||||||
router.push('/')
|
router.push('/')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data?.detail || 'Ошибка регистрации'
|
error.value = e.response?.data?.detail || 'Ошибка регистрации'
|
||||||
|
|||||||
@@ -21,11 +21,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="showAddTrack = false">
|
<Modal v-if="showAddTrack" title="Добавить в очередь" @close="closeAddTrackModal">
|
||||||
|
<div class="filters-section">
|
||||||
|
<div class="search-filters">
|
||||||
|
<input
|
||||||
|
v-model="searchTitle"
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="searchArtist"
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по артисту..."
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-filters">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="filterMyTracks" />
|
||||||
|
<span>Мои треки</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="filterNotInQueue" />
|
||||||
|
<span>Не добавленные в комнату</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="add-track-controls">
|
||||||
|
<div class="selection-info">
|
||||||
|
<span>Найдено: {{ filteredTracks.length }}</span>
|
||||||
|
<span v-if="selectedTracks.length > 0" class="selected-count">
|
||||||
|
Выбрано: {{ selectedTracks.length }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="selectedTracks.length > 0"
|
||||||
|
class="btn-text"
|
||||||
|
@click="clearSelection"
|
||||||
|
>
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="selectedTracks.length === 0"
|
||||||
|
@click="addSelectedTracks"
|
||||||
|
>
|
||||||
|
Добавить выбранные ({{ selectedTracks.length }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="addTrackError" class="error-message">{{ addTrackError }}</p>
|
||||||
|
<p v-if="addTrackSuccess" class="success-message">{{ addTrackSuccess }}</p>
|
||||||
<TrackList
|
<TrackList
|
||||||
:tracks="tracksStore.tracks"
|
:tracks="filteredTracks"
|
||||||
selectable
|
:queue-track-ids="queueTrackIds"
|
||||||
@select="addTrackToQueue"
|
:selected-track-ids="selectedTracks"
|
||||||
|
multi-select
|
||||||
|
@toggle-select="toggleTrackSelection"
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,11 +86,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useRoomStore } from '../stores/room'
|
import { useRoomStore } from '../stores/room'
|
||||||
import { useTracksStore } from '../stores/tracks'
|
import { useTracksStore } from '../stores/tracks'
|
||||||
import { useActiveRoomStore } from '../stores/activeRoom'
|
import { useActiveRoomStore } from '../stores/activeRoom'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import Queue from '../components/room/Queue.vue'
|
import Queue from '../components/room/Queue.vue'
|
||||||
import ParticipantsList from '../components/room/ParticipantsList.vue'
|
import ParticipantsList from '../components/room/ParticipantsList.vue'
|
||||||
import ChatWindow from '../components/chat/ChatWindow.vue'
|
import ChatWindow from '../components/chat/ChatWindow.vue'
|
||||||
@@ -48,10 +102,57 @@ const route = useRoute()
|
|||||||
const roomStore = useRoomStore()
|
const roomStore = useRoomStore()
|
||||||
const tracksStore = useTracksStore()
|
const tracksStore = useTracksStore()
|
||||||
const activeRoomStore = useActiveRoomStore()
|
const activeRoomStore = useActiveRoomStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const roomId = route.params.id
|
const roomId = route.params.id
|
||||||
const room = ref(null)
|
const room = ref(null)
|
||||||
const showAddTrack = ref(false)
|
const showAddTrack = ref(false)
|
||||||
|
const addTrackError = ref('')
|
||||||
|
const addTrackSuccess = ref('')
|
||||||
|
const selectedTracks = ref([])
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const searchTitle = ref('')
|
||||||
|
const searchArtist = ref('')
|
||||||
|
const filterMyTracks = ref(false)
|
||||||
|
const filterNotInQueue = ref(false)
|
||||||
|
|
||||||
|
const queueTrackIds = computed(() => {
|
||||||
|
return roomStore.queue.map(track => track.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTracks = computed(() => {
|
||||||
|
let tracks = tracksStore.tracks
|
||||||
|
|
||||||
|
// Filter by title
|
||||||
|
if (searchTitle.value.trim()) {
|
||||||
|
const searchLower = searchTitle.value.toLowerCase()
|
||||||
|
tracks = tracks.filter(track =>
|
||||||
|
track.title.toLowerCase().includes(searchLower)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by artist
|
||||||
|
if (searchArtist.value.trim()) {
|
||||||
|
const searchLower = searchArtist.value.toLowerCase()
|
||||||
|
tracks = tracks.filter(track =>
|
||||||
|
track.artist.toLowerCase().includes(searchLower)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter my tracks
|
||||||
|
if (filterMyTracks.value) {
|
||||||
|
const currentUserId = authStore.user?.id
|
||||||
|
tracks = tracks.filter(track => track.uploaded_by === currentUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter not in queue
|
||||||
|
if (filterNotInQueue.value) {
|
||||||
|
tracks = tracks.filter(track => !queueTrackIds.value.includes(track.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await roomStore.fetchRoom(roomId)
|
await roomStore.fetchRoom(roomId)
|
||||||
@@ -69,9 +170,59 @@ function playTrack(track) {
|
|||||||
activeRoomStore.sendPlayerAction('set_track', null, track.id)
|
activeRoomStore.sendPlayerAction('set_track', null, track.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addTrackToQueue(track) {
|
function toggleTrackSelection(trackId) {
|
||||||
await roomStore.addToQueue(roomId, track.id)
|
const index = selectedTracks.value.indexOf(trackId)
|
||||||
|
if (index === -1) {
|
||||||
|
selectedTracks.value.push(trackId)
|
||||||
|
} else {
|
||||||
|
selectedTracks.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedTracks.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddTrackModal() {
|
||||||
showAddTrack.value = false
|
showAddTrack.value = false
|
||||||
|
clearSelection()
|
||||||
|
addTrackError.value = ''
|
||||||
|
addTrackSuccess.value = ''
|
||||||
|
// Reset filters
|
||||||
|
searchTitle.value = ''
|
||||||
|
searchArtist.value = ''
|
||||||
|
filterMyTracks.value = false
|
||||||
|
filterNotInQueue.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSelectedTracks() {
|
||||||
|
if (selectedTracks.value.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
addTrackError.value = ''
|
||||||
|
addTrackSuccess.value = ''
|
||||||
|
|
||||||
|
const result = await roomStore.addMultipleToQueue(roomId, selectedTracks.value)
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
addTrackSuccess.value = `Добавлено: ${result.added}, пропущено (уже в очереди): ${result.skipped}`
|
||||||
|
} else {
|
||||||
|
addTrackSuccess.value = `Добавлено треков: ${result.added}`
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection()
|
||||||
|
|
||||||
|
// Close modal after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
closeAddTrackModal()
|
||||||
|
}, 1500)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.data?.detail === 'All tracks already in queue') {
|
||||||
|
addTrackError.value = 'Все выбранные треки уже в очереди'
|
||||||
|
} else {
|
||||||
|
addTrackError.value = e.response?.data?.detail || 'Ошибка добавления треков'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeFromQueue(track) {
|
async function removeFromQueue(track) {
|
||||||
@@ -134,6 +285,110 @@ async function removeFromQueue(track) {
|
|||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filters-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #252525;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--color-primary, #1db954);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-primary, #1db954);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-track-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-count {
|
||||||
|
color: var(--color-primary, #1db954);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary, #1db954);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
color: #4caf50;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.room-layout {
|
.room-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user