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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -45,3 +45,7 @@ class PlayerAction(BaseModel):
|
||||
|
||||
class QueueAdd(BaseModel):
|
||||
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 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:
|
||||
|
||||
Reference in New Issue
Block a user