Add global mini-player and improve configuration

- Add global activeRoom store for persistent WebSocket connection
- Add MiniPlayer component for playback controls across pages
- Add chunked S3 streaming with 64KB chunks and Range support
- Add queue item removal button
- Move DB credentials to environment variables
- Update .env.example with DB configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-12 15:02:02 +03:00
parent 2f1e1f35e3
commit f77a453158
12 changed files with 572 additions and 119 deletions

View File

@@ -1,6 +1,6 @@
import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Request, Response
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
@@ -11,7 +11,7 @@ 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_content
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()
@@ -172,9 +172,11 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
if not track:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Track not found")
# Get full file content
content = get_file_content(track.s3_key)
file_size = len(content)
# 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")
@@ -192,11 +194,8 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
end = min(end, file_size - 1)
content_length = end - start + 1
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
return Response(
content=content[start:end + 1],
return StreamingResponse(
stream_file_chunks(track.s3_key, start, end),
status_code=206,
media_type="audio/mpeg",
headers={
@@ -207,12 +206,9 @@ async def stream_track(track_id: uuid.UUID, request: Request, db: AsyncSession =
}
)
# Encode filename for non-ASCII characters
encoded_filename = quote(f"{track.title}.mp3")
# No range - return full file
return Response(
content=content,
# No range - stream full file
return StreamingResponse(
stream_file_chunks(track.s3_key),
media_type="audio/mpeg",
headers={
"Accept-Ranges": "bytes",

View File

@@ -75,3 +75,40 @@ def get_file_content(s3_key: str) -> bytes:
client = get_s3_client()
response = client.get_object(Bucket=settings.s3_bucket_name, Key=s3_key)
return response["Body"].read()
def get_file_size(s3_key: str) -> int:
"""Get file size from S3 without downloading"""
client = get_s3_client()
response = client.head_object(Bucket=settings.s3_bucket_name, Key=s3_key)
return response["ContentLength"]
def get_file_range(s3_key: str, start: int, end: int):
"""Get a range of bytes from S3 file"""
client = get_s3_client()
response = client.get_object(
Bucket=settings.s3_bucket_name,
Key=s3_key,
Range=f"bytes={start}-{end}"
)
return response["Body"].read()
def stream_file_chunks(s3_key: str, start: int = 0, end: int = None, chunk_size: int = 64 * 1024):
"""Stream file from S3 in chunks (default 64KB chunks)"""
client = get_s3_client()
if end is None:
range_header = f"bytes={start}-"
else:
range_header = f"bytes={start}-{end}"
response = client.get_object(
Bucket=settings.s3_bucket_name,
Key=s3_key,
Range=range_header
)
for chunk in response["Body"].iter_chunks(chunk_size=chunk_size):
yield chunk