Files
enigFM/backend/app/routers/tracks.py
mamonov.ep 487da10365 Improve mini-player and add periodic sync
- Redesign mini-player: progress bar on top, centered controls
- Add vertical volume slider with popup on hover
- Add volume percentage display
- Add custom speaker SVG icons
- Add periodic sync every 10 seconds for playback synchronization
- Broadcast user_joined when connecting via WebSocket
- Disable nginx proxy buffering for streaming
- Allow extra env variables in pydantic settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 16:53:56 +03:00

223 lines
7.2 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(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Track).order_by(Track.created_at.desc()))
return result.scalars().all()
@router.post("/upload", response_model=TrackResponse)
async def upload_track(
file: UploadFile = File(...),
title: str = Form(None),
artist: str = Form(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Check file type
if not file.content_type or not file.content_type.startswith("audio/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an audio file",
)
# Read file content
content = await file.read()
file_size = len(content)
# Check file size
max_size = settings.max_file_size_mb * 1024 * 1024
if file_size > max_size:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds {settings.max_file_size_mb}MB limit",
)
# Check storage limit
if not await can_upload_file(file_size):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Storage limit exceeded",
)
# Get duration and metadata from MP3
try:
audio = MP3(BytesIO(content))
duration = int(audio.info.length * 1000) # Convert to milliseconds
# Extract ID3 tags if title/artist not provided
if not title or not artist:
tags = audio.tags
if tags:
# TIT2 = Title, TPE1 = Artist
if not title and tags.get("TIT2"):
title = str(tags.get("TIT2"))
if not artist and tags.get("TPE1"):
artist = str(tags.get("TPE1"))
# Fallback to filename if still no title
if not title:
title = file.filename.rsplit(".", 1)[0] if file.filename else "Unknown"
if not artist:
artist = "Unknown"
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Could not read audio file",
)
# Upload to S3
s3_key = f"tracks/{uuid.uuid4()}.mp3"
await upload_file(content, s3_key)
# Create track record
track = Track(
title=title,
artist=artist,
duration=duration,
s3_key=s3_key,
file_size=file_size,
uploaded_by=current_user.id,
)
db.add(track)
await db.flush()
return track
@router.get("/{track_id}", response_model=TrackWithUrl)
async def get_track(track_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Track).where(Track.id == track_id))
track = result.scalar_one_or_none()
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
url = generate_presigned_url(track.s3_key)
return TrackWithUrl(
id=track.id,
title=track.title,
artist=track.artist,
duration=track.duration,
file_size=track.file_size,
uploaded_by=track.uploaded_by,
created_at=track.created_at,
url=url,
)
@router.delete("/{track_id}")
async def delete_track(
track_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Track).where(Track.id == track_id))
track = result.scalar_one_or_none()
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
if track.uploaded_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not track owner")
# Delete from S3
await delete_file(track.s3_key)
# Delete from DB
await db.delete(track)
return {"status": "deleted"}
@router.get("/storage/info")
async def get_storage_info(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(func.sum(Track.file_size)))
total_size = result.scalar() or 0
max_size = settings.max_storage_gb * 1024 * 1024 * 1024
return {
"used_bytes": total_size,
"max_bytes": max_size,
"used_gb": round(total_size / (1024 * 1024 * 1024), 2),
"max_gb": settings.max_storage_gb,
}
@router.get("/{track_id}/stream")
async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)):
"""Stream audio file through backend with Range support (bypasses S3 SSL issues)"""
result = await db.execute(select(Track).where(Track.id == track_id))
track = result.scalar_one_or_none()
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
# Get file size from S3 (without downloading)
file_size = get_file_size(track.s3_key)
# 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",
}
)