Backend optimizations: - GZip middleware for compressed responses - Concurrent WebSocket broadcast - Skip status polling when no clients connected - Deduplicated token validation with caching - Fire-and-forget HA state callbacks - Single stat() per browser item - Metadata caching (LRU) - M3U playlist optimization - Autostart setup (Task Scheduler + hidden VBS launcher) Frontend code optimizations: - Fix thumbnail blob URL memory leak - Fix WebSocket ping interval leak on reconnect - Skip artwork re-fetch when same track playing - Deduplicate volume slider logic - Extract magic numbers into named constants - Standardize error handling with toast notifications - Cache play/pause SVG constants - Loading state management for async buttons - Request deduplication for rapid clicks - Cache 30+ DOM element references - Deduplicate volume updates over WebSocket Frontend design improvements: - Progress bar seek thumb and hover expansion - Custom themed scrollbars - Toast notification accent border strips - Keyboard focus-visible states - Album art ambient glow effect - Animated sliding tab indicator - Mini-player top progress line - Empty state SVG illustrations - Responsive tablet breakpoint (601-900px) - Horizontal player layout on wide screens (>900px) - Glassmorphism mini-player with backdrop blur - Vinyl spin animation (toggleable) - Table horizontal scroll on narrow screens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
330 lines
9.6 KiB
Python
330 lines
9.6 KiB
Python
"""Media control API endpoints."""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
|
from fastapi import status
|
|
from fastapi.responses import Response
|
|
|
|
from ..auth import verify_token, verify_token_or_query
|
|
from ..config import settings
|
|
from ..models import MediaStatus, VolumeRequest, SeekRequest
|
|
from ..services import get_media_controller, get_current_album_art
|
|
from ..services.websocket_manager import ws_manager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/media", tags=["media"])
|
|
|
|
|
|
def _run_callback(callback_name: str) -> None:
|
|
"""Fire-and-forget a callback if configured. Failures are logged but don't block."""
|
|
if not settings.callbacks or callback_name not in settings.callbacks:
|
|
return
|
|
|
|
async def _execute():
|
|
from .scripts import _run_script
|
|
|
|
try:
|
|
callback = settings.callbacks[callback_name]
|
|
loop = asyncio.get_event_loop()
|
|
result = await loop.run_in_executor(
|
|
None,
|
|
lambda: _run_script(
|
|
command=callback.command,
|
|
timeout=callback.timeout,
|
|
shell=callback.shell,
|
|
working_dir=callback.working_dir,
|
|
),
|
|
)
|
|
if result["exit_code"] != 0:
|
|
logger.warning(
|
|
"Callback %s failed with exit code %s: %s",
|
|
callback_name,
|
|
result["exit_code"],
|
|
result["stderr"],
|
|
)
|
|
except Exception as e:
|
|
logger.error("Callback %s error: %s", callback_name, e)
|
|
|
|
asyncio.create_task(_execute())
|
|
|
|
|
|
@router.get("/status", response_model=MediaStatus)
|
|
async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus:
|
|
"""Get current media playback status.
|
|
|
|
Returns:
|
|
Current playback state, media info, volume, etc.
|
|
"""
|
|
controller = get_media_controller()
|
|
return await controller.get_status()
|
|
|
|
|
|
@router.post("/play")
|
|
async def play(_: str = Depends(verify_token)) -> dict:
|
|
"""Resume or start playback.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
controller = get_media_controller()
|
|
success = await controller.play()
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to start playback - no active media session",
|
|
)
|
|
_run_callback("on_play")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/pause")
|
|
async def pause(_: str = Depends(verify_token)) -> dict:
|
|
"""Pause playback.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
controller = get_media_controller()
|
|
success = await controller.pause()
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to pause - no active media session",
|
|
)
|
|
_run_callback("on_pause")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/stop")
|
|
async def stop(_: str = Depends(verify_token)) -> dict:
|
|
"""Stop playback.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
controller = get_media_controller()
|
|
success = await controller.stop()
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to stop - no active media session",
|
|
)
|
|
_run_callback("on_stop")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/next")
|
|
async def next_track(_: str = Depends(verify_token)) -> dict:
|
|
"""Skip to next track.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
controller = get_media_controller()
|
|
success = await controller.next_track()
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to skip - no active media session",
|
|
)
|
|
_run_callback("on_next")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/previous")
|
|
async def previous_track(_: str = Depends(verify_token)) -> dict:
|
|
"""Go to previous track.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
controller = get_media_controller()
|
|
success = await controller.previous_track()
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to go back - no active media session",
|
|
)
|
|
_run_callback("on_previous")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/volume")
|
|
async def set_volume(
|
|
request: VolumeRequest, _: str = Depends(verify_token)
|
|
) -> dict:
|
|
"""Set the system volume.
|
|
|
|
Args:
|
|
request: Volume level (0-100)
|
|
|
|
Returns:
|
|
Success status with new volume level
|
|
"""
|
|
controller = get_media_controller()
|
|
success = await controller.set_volume(request.volume)
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to set volume",
|
|
)
|
|
_run_callback("on_volume")
|
|
return {"success": True, "volume": request.volume}
|
|
|
|
|
|
@router.post("/mute")
|
|
async def toggle_mute(_: str = Depends(verify_token)) -> dict:
|
|
"""Toggle mute state.
|
|
|
|
Returns:
|
|
Success status with new mute state
|
|
"""
|
|
controller = get_media_controller()
|
|
muted = await controller.toggle_mute()
|
|
_run_callback("on_mute")
|
|
return {"success": True, "muted": muted}
|
|
|
|
|
|
@router.post("/seek")
|
|
async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict:
|
|
"""Seek to a position in the current track.
|
|
|
|
Args:
|
|
request: Position in seconds
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
controller = get_media_controller()
|
|
success = await controller.seek(request.position)
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="Failed to seek - no active media session or seek not supported",
|
|
)
|
|
_run_callback("on_seek")
|
|
return {"success": True, "position": request.position}
|
|
|
|
|
|
@router.post("/turn_on")
|
|
async def turn_on(_: str = Depends(verify_token)) -> dict:
|
|
"""Execute turn on callback if configured.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
_run_callback("on_turn_on")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/turn_off")
|
|
async def turn_off(_: str = Depends(verify_token)) -> dict:
|
|
"""Execute turn off callback if configured.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
_run_callback("on_turn_off")
|
|
return {"success": True}
|
|
|
|
|
|
@router.post("/toggle")
|
|
async def toggle(_: str = Depends(verify_token)) -> dict:
|
|
"""Execute toggle callback if configured.
|
|
|
|
Returns:
|
|
Success status
|
|
"""
|
|
_run_callback("on_toggle")
|
|
return {"success": True}
|
|
|
|
|
|
@router.get("/artwork")
|
|
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
|
"""Get the current album artwork.
|
|
|
|
Returns:
|
|
The album art image as PNG/JPEG
|
|
"""
|
|
art_bytes = get_current_album_art()
|
|
if art_bytes is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No album artwork available",
|
|
)
|
|
|
|
# Try to detect image type from magic bytes
|
|
content_type = "image/png" # Default
|
|
if art_bytes[:3] == b"\xff\xd8\xff":
|
|
content_type = "image/jpeg"
|
|
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
|
content_type = "image/png"
|
|
elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP":
|
|
content_type = "image/webp"
|
|
|
|
return Response(content=art_bytes, media_type=content_type)
|
|
|
|
|
|
@router.websocket("/ws")
|
|
async def websocket_endpoint(
|
|
websocket: WebSocket,
|
|
token: str = Query(..., description="API authentication token"),
|
|
) -> None:
|
|
"""WebSocket endpoint for real-time media status updates.
|
|
|
|
Authentication is done via query parameter since WebSocket
|
|
doesn't support custom headers in the browser.
|
|
|
|
Messages sent to client:
|
|
- {"type": "status", "data": {...}} - Initial status on connect
|
|
- {"type": "status_update", "data": {...}} - Status changes
|
|
- {"type": "error", "message": "..."} - Error messages
|
|
|
|
Client can send:
|
|
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
|
|
- {"type": "get_status"} - Request current status
|
|
"""
|
|
# Verify token
|
|
from ..auth import get_token_label, token_label_var
|
|
|
|
label = get_token_label(token) if token else None
|
|
if label is None:
|
|
await websocket.close(code=4001, reason="Invalid authentication token")
|
|
return
|
|
|
|
# Set label in context for logging
|
|
token_label_var.set(label)
|
|
|
|
await ws_manager.connect(websocket)
|
|
|
|
try:
|
|
while True:
|
|
# Wait for messages from client (for keepalive/ping)
|
|
data = await websocket.receive_json()
|
|
|
|
if data.get("type") == "ping":
|
|
await websocket.send_json({"type": "pong"})
|
|
elif data.get("type") == "get_status":
|
|
# Allow manual status request
|
|
controller = get_media_controller()
|
|
status_data = await controller.get_status()
|
|
await websocket.send_json({
|
|
"type": "status",
|
|
"data": status_data.model_dump(),
|
|
})
|
|
elif data.get("type") == "volume":
|
|
# Low-latency volume control via WebSocket
|
|
volume = data.get("volume")
|
|
if volume is not None:
|
|
controller = get_media_controller()
|
|
await controller.set_volume(int(volume))
|
|
|
|
except WebSocketDisconnect:
|
|
await ws_manager.disconnect(websocket)
|
|
except Exception as e:
|
|
logger.error("WebSocket error: %s", e)
|
|
await ws_manager.disconnect(websocket)
|