- Remove committed .coverage and test-results/ (196K screenshots); gitignore them - Fix hardcoded /home/userpath + venv/bin/python in test_endpoints.py (relative backend dir + sys.executable) - Fix concatenated 'import json' in settings.py; bare excepts -> Exception; SQLAlchemy-safe is_active.is_(True); __all__ on models/schemas barrels - ruff clean (93 fixes), MIT LICENSE, PLAN.md -> docs/, README Tests section - 300 tests pass, 93.7% coverage
163 lines
5.7 KiB
Python
163 lines
5.7 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional
|
|
from pydantic import BaseModel
|
|
from ..db.database import get_db
|
|
from ..schemas.shareplay import SharePlayCueResponse
|
|
from ..services.shareplay import SharePlayManager
|
|
|
|
|
|
class RoomIdRequest(BaseModel):
|
|
room_id: str
|
|
|
|
|
|
class ControlRequest(BaseModel):
|
|
room_id: str
|
|
type: str
|
|
payload: Optional[dict] = None
|
|
|
|
router = APIRouter(prefix="/api/shareplay", tags=["shareplay"])
|
|
manager = SharePlayManager()
|
|
|
|
# Track active WebSocket connections per room
|
|
active_connections: dict[str, list[WebSocket]] = {}
|
|
|
|
|
|
@router.post("/create")
|
|
def create_room(db: Session = Depends(get_db)):
|
|
return manager.create_room(db, creator="user")
|
|
|
|
|
|
@router.post("/join")
|
|
def join_room(data: RoomIdRequest, db: Session = Depends(get_db)):
|
|
result = manager.join_room(db, data.room_id)
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
return result
|
|
|
|
|
|
@router.post("/leave")
|
|
def leave_room(data: RoomIdRequest, db: Session = Depends(get_db)):
|
|
success = manager.leave_room(db, data.room_id)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
return {"message": "Left room"}
|
|
|
|
|
|
@router.get("/cue")
|
|
def get_cue(room_id: str, db: Session = Depends(get_db)):
|
|
items = manager.get_cue(db, room_id)
|
|
return SharePlayCueResponse(items=items, next_song=None)
|
|
|
|
|
|
@router.post("/cue")
|
|
def add_to_cue(room_id: str, song_id: str, db: Session = Depends(get_db)):
|
|
success = manager.add_to_cue(db, room_id, song_id)
|
|
if not success:
|
|
raise HTTPException(status_code=400, detail="Failed to add to cue")
|
|
return {"message": "Added to cue"}
|
|
|
|
|
|
@router.post("/control")
|
|
def send_control(data: ControlRequest, db: Session = Depends(get_db)):
|
|
state = manager.get_state(data.room_id)
|
|
if not state:
|
|
raise HTTPException(status_code=404, detail="Room not found")
|
|
|
|
if data.type == "play":
|
|
manager.update_state(data.room_id, is_playing=True)
|
|
elif data.type == "pause":
|
|
manager.update_state(data.room_id, is_playing=False)
|
|
elif data.type == "seek" and data.payload:
|
|
manager.update_state(data.room_id, position=data.payload.get("position", 0))
|
|
elif data.type == "shuffle":
|
|
current = manager.get_state(data.room_id)
|
|
manager.update_state(data.room_id, shuffle=not current.get("shuffle", False))
|
|
|
|
return {"message": f"Command '{data.type}' sent"}
|
|
|
|
|
|
# WebSocket endpoint
|
|
@router.websocket("/ws/{room_id}")
|
|
async def websocket_endpoint(websocket: WebSocket, room_id: str): # pragma: no cover
|
|
await websocket.accept()
|
|
|
|
# Register connection
|
|
if room_id not in active_connections:
|
|
active_connections[room_id] = []
|
|
active_connections[room_id].append(websocket)
|
|
|
|
# Send initial state
|
|
state = manager.get_state(room_id)
|
|
if state:
|
|
await websocket.send_json({"type": "state", "data": state})
|
|
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_text()
|
|
import json
|
|
message = json.loads(data)
|
|
|
|
if message.get("type") == "control":
|
|
cmd = message.get("payload", {})
|
|
state = manager.get_state(room_id)
|
|
if state and cmd.get("type") == "play":
|
|
manager.update_state(room_id, is_playing=True)
|
|
# Broadcast to all connections in room
|
|
for conn in active_connections.get(room_id, []):
|
|
try:
|
|
await conn.send_json({
|
|
"type": "playback_update",
|
|
"data": {"is_playing": True}
|
|
})
|
|
except Exception:
|
|
pass
|
|
elif state and cmd.get("type") == "pause":
|
|
manager.update_state(room_id, is_playing=False)
|
|
for conn in active_connections.get(room_id, []):
|
|
try:
|
|
await conn.send_json({
|
|
"type": "playback_update",
|
|
"data": {"is_playing": False}
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
await websocket.send_json({"type": "ack", "data": message})
|
|
|
|
elif message.get("type") == "chat":
|
|
# Broadcast chat message to all room members
|
|
chat_msg = message.get("payload", {})
|
|
for conn in active_connections.get(room_id, []):
|
|
try:
|
|
await conn.send_json({
|
|
"type": "chat",
|
|
"data": chat_msg
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
elif message.get("type") == "seek":
|
|
pos = message.get("payload", {}).get("position", 0)
|
|
manager.update_state(room_id, position=pos)
|
|
for conn in active_connections.get(room_id, []):
|
|
try:
|
|
await conn.send_json({
|
|
"type": "seek_update",
|
|
"data": {"position": pos}
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
except WebSocketDisconnect:
|
|
# Clean up disconnected client
|
|
if room_id in active_connections:
|
|
try:
|
|
active_connections[room_id].remove(websocket)
|
|
except ValueError:
|
|
pass
|
|
# Remove room entry if no connections left
|
|
if not active_connections[room_id]:
|
|
del active_connections[room_id]
|