"""Media control API endpoints.""" import asyncio import logging from fastapi import ( APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status, ) from fastapi.responses import Response from ..auth import verify_token, verify_token_or_query from ..config import settings from ..models import MediaStatus, SeekRequest, VolumeRequest from ..services import get_current_album_art_async, get_media_controller from ..services.websocket_manager import ws_manager logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/media", tags=["media"]) # Strong refs to background tasks so the asyncio GC can't drop them before # they run. Mirrors the pattern used in routes/browser.py. _background_callback_tasks: set[asyncio.Task] = set() 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(): # Use the dedicated callback executor (not the default loop pool) so a # misbehaving callback can't starve the rest of the app's sync tasks. from ..services.audit_log import record_script_execution from .callbacks import _callback_executor from .scripts import _run_script try: callback = settings.callbacks[callback_name] loop = asyncio.get_running_loop() result = await loop.run_in_executor( _callback_executor, lambda: _run_script( command=callback.command, timeout=callback.timeout, shell=callback.shell, working_dir=callback.working_dir, ), ) record_script_execution( kind="event-callback", name=callback_name, exit_code=result["exit_code"], duration=result.get("execution_time"), stdout=result.get("stdout"), stderr=result.get("stderr"), ) 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) from ..services.audit_log import record_script_execution as _rec _rec( kind="event-callback", name=callback_name, exit_code=None, duration=None, error=str(e), ) task = asyncio.create_task(_execute()) _background_callback_tasks.add(task) task.add_done_callback(_background_callback_tasks.discard) @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( request: Request, _: str = Depends(verify_token_or_query), ) -> Response: """Get the current album artwork. Returns the bytes with a content-derived ETag so the browser can serve a 304 when the same track is re-requested. """ art_bytes = await get_current_album_art_async() if art_bytes is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No album artwork available", ) # Detect image type from magic bytes 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 len(art_bytes) > 12 and art_bytes[8:12] == b"WEBP": content_type = "image/webp" elif art_bytes[:2] == b"BM": content_type = "image/bmp" else: content_type = "application/octet-stream" # Content-derived ETag (blake2b-128 — non-crypto cache key, ruff S324-safe) import hashlib etag = '"' + hashlib.blake2b(art_bytes, digest_size=16).hexdigest() + '"' if request.headers.get("if-none-match") == etag: return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag}) return Response( content=art_bytes, media_type=content_type, headers={ "ETag": etag, "Cache-Control": "private, max-age=0, must-revalidate", }, ) @router.get("/visualizer/status") async def visualizer_status(_: str = Depends(verify_token)) -> dict: """Check if audio visualizer is available and running. ``available`` is True only when both numpy + soundcard import. ``unavailable_reason`` carries a short human-readable hint when False or when a usable loopback device was not found — useful on Linux where PulseAudio/PipeWire may not expose monitor sources to a headless systemd-as-user service. """ import platform as _platform from ..services.audio_analyzer import get_audio_analyzer analyzer = get_audio_analyzer() reason: str | None = None if not analyzer.available: reason = "soundcard or numpy is not installed" elif getattr(analyzer, "_unavailable", False): if _platform.system() == "Linux": reason = ( "No loopback audio device found. On Linux this requires a " "running PulseAudio/PipeWire session with monitor sources " "(systemd user service: ensure DBUS_SESSION_BUS_ADDRESS + " "XDG_RUNTIME_DIR are set)." ) elif _platform.system() == "Darwin": reason = ( "No loopback audio device found. macOS does not expose system " "loopback by default — install BlackHole or Soundflower." ) else: reason = "No loopback audio device found" return { "available": analyzer.available, "running": analyzer.running, "current_device": analyzer.current_device, "unavailable_reason": reason, } @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_running_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) # Persist selection to config.yaml so it survives server restarts if success: from ..config_manager import config_manager config_manager.set_setting("visualizer_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 | None = Query(None, description="API authentication token (legacy)"), ) -> None: """WebSocket endpoint for real-time media status updates. Authentication is accepted from two sources, in priority order: 1. ``Sec-WebSocket-Protocol`` subprotocol of the form ``media-server.token.``. This is the preferred path because the token never lands in the URL, request logs, or browser history. The browser WebSocket API supports custom subprotocols natively. 2. ``?token=`` query parameter (legacy, kept for back-compat with older clients and the HA integration). 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 """ # Pull token from subprotocol if present. WebSocket spec lets either side # negotiate exactly one subprotocol back; we accept the token one and # answer with the same string so browsers consider the negotiation # successful. subprotocol_token: str | None = None accept_subprotocol: str | None = None raw_protocols = websocket.headers.get("sec-websocket-protocol", "") for proto in (p.strip() for p in raw_protocols.split(",") if p.strip()): if proto.startswith("media-server.token."): subprotocol_token = proto[len("media-server.token."):] accept_subprotocol = proto break effective_token = subprotocol_token or token # Origin check — block CSWSH from third-party LAN pages. Accept the same # set of origins as CORS plus the default localhost loopback, AND any # same-origin connection (where Origin matches the request's Host header). # Same-origin is inherently safe from CSWSH because CSWSH is a *cross*- # origin attack — without this, binding to 0.0.0.0 and accessing the UI # via a LAN IP would have its WebSocket rejected by the browser-sent # Origin, which the static allowlist can't anticipate. allowed_origins = set( settings.cors_origins or [ f"http://localhost:{settings.port}", f"http://127.0.0.1:{settings.port}", ] ) origin = websocket.headers.get("origin") # Same-origin connections from native apps may omit Origin entirely; only # reject when an Origin is present AND not in the allow-list. if origin is not None and origin not in allowed_origins: host_header = websocket.headers.get("host", "") # Origin uses http/https; match against both scheme variants of Host # so HTTPS deployments without an explicit cors_origins still work. same_origin_candidates = ( {f"http://{host_header}", f"https://{host_header}"} if host_header else set() ) if origin not in same_origin_candidates: await websocket.close(code=4003, reason="Origin not allowed") return # Verify token from ..auth import auth_enabled, get_token_label, token_label_var if auth_enabled(): label = get_token_label(effective_token) if effective_token else None if label is None: await websocket.close(code=4001, reason="Invalid authentication token") return token_label_var.set(label) else: token_label_var.set("anonymous") # Accept with the negotiated subprotocol if one was used. Starlette's # connect() calls accept() with no subprotocol — we need to accept first # explicitly to echo the subprotocol back, then hand off to the manager. if accept_subprotocol is not None: await websocket.accept(subprotocol=accept_subprotocol) await ws_manager.connect(websocket, already_accepted=True) else: await ws_manager.connect(websocket) try: while True: # Wait for messages from client (for keepalive/ping) data = await websocket.receive_json() msg_type = data.get("type") if isinstance(data, dict) else None if msg_type == "ping": await websocket.send_json({"type": "pong"}) elif msg_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 msg_type == "volume": # Low-latency volume control via WebSocket. Coerce, clamp, and # never drop the socket on a single bad message — that would # turn the WS into a one-shot DoS for any holder of a token. try: volume = int(data.get("volume")) except (TypeError, ValueError): continue volume = max(0, min(100, volume)) controller = get_media_controller() await controller.set_volume(volume) elif msg_type == "enable_visualizer": await ws_manager.subscribe_visualizer(websocket) elif msg_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)