"""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.get("/visualizer/status") async def visualizer_status(_: str = Depends(verify_token)) -> dict: """Check if audio visualizer is available and running.""" from ..services.audio_analyzer import get_audio_analyzer analyzer = get_audio_analyzer() return { "available": analyzer.available, "running": analyzer.running, "current_device": analyzer.current_device, } @router.get("/visualizer/devices") async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]: """List available loopback audio devices for the visualizer.""" from ..services.audio_analyzer import AudioAnalyzer loop = asyncio.get_event_loop() return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices) @router.post("/visualizer/device") async def set_visualizer_device( request: dict, _: str = Depends(verify_token), ) -> dict: """Set the loopback audio device for the visualizer. Body: {"device_name": "Device Name" | null} Passing null resets to auto-detect. """ from ..services.audio_analyzer import get_audio_analyzer device_name = request.get("device_name") analyzer = get_audio_analyzer() # set_device() handles stop/start internally if capture was running success = analyzer.set_device(device_name) return { "success": success, "current_device": analyzer.current_device, "running": analyzer.running, } @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)) elif data.get("type") == "enable_visualizer": await ws_manager.subscribe_visualizer(websocket) elif data.get("type") == "disable_visualizer": await ws_manager.unsubscribe_visualizer(websocket) except WebSocketDisconnect: await ws_manager.disconnect(websocket) except Exception as e: logger.error("WebSocket error: %s", e) await ws_manager.disconnect(websocket)