fb56e6cdc0
Device choice now survives server restarts. Falls back to default if the saved device is no longer available.
387 lines
12 KiB
Python
387 lines
12 KiB
Python
"""Media control API endpoints."""
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, 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, get_media_controller
|
|
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)
|
|
|
|
# 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 = 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 auth_enabled, get_token_label, token_label_var
|
|
|
|
if auth_enabled():
|
|
label = get_token_label(token) if 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")
|
|
|
|
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)
|