Files
enigFM/backend/app/routers/tracks.py
mamonov.ep 8a2ea5b4af 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>
2025-12-19 19:22:35 +03:00

269 lines
8.6 KiB
Python

import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request
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_size, stream_file_chunks
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(
my: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
query = select(Track)
if my:
query = query.where(Track.uploaded_by == current_user.id)
query = query.order_by(Track.created_at.desc())
result = await db.execute(query)
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(...),
title: str = Form(None),
artist: str = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
track, error = await _process_single_track(file, title, artist, current_user)
if error:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(error),
)
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))
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 file size from S3 (without downloading)
file_size = get_file_size(track.s3_key)
if file_size is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track file not found in storage")
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
# 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
return StreamingResponse(
stream_file_chunks(track.s3_key, start, end),
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}",
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
}
)
# No range - stream full file
return StreamingResponse(
stream_file_chunks(track.s3_key),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
"X-Accel-Buffering": "no",
"Cache-Control": "no-cache",
}
)