Refactor project into two standalone components

Split monorepo into separate units for future independent repositories:
- media-server/: Standalone FastAPI server with own README, requirements,
  config example, and CLAUDE.md
- haos-integration/: HACS-ready Home Assistant integration with hacs.json,
  own README, and CLAUDE.md

Both components now have their own .gitignore files and can be easily
extracted into separate repositories.

Also adds custom icon support for scripts configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 14:36:23 +03:00
parent 5519e449cd
commit e26df64e4b
44 changed files with 367 additions and 105 deletions

View File

@@ -0,0 +1,7 @@
"""API route modules."""
from .health import router as health_router
from .media import router as media_router
from .scripts import router as scripts_router
__all__ = ["health_router", "media_router", "scripts_router"]

View File

@@ -0,0 +1,22 @@
"""Health check endpoint."""
import platform
from typing import Any
from fastapi import APIRouter
router = APIRouter(prefix="/api", tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, Any]:
"""Health check endpoint - no authentication required.
Returns:
Health status and server information
"""
return {
"status": "healthy",
"platform": platform.system(),
"version": "1.0.0",
}

View File

@@ -0,0 +1,242 @@
"""Media control API endpoints."""
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"])
@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",
)
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",
)
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",
)
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",
)
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",
)
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",
)
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()
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",
)
return {"success": True, "position": request.position}
@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
if token != settings.api_token:
await websocket.close(code=4001, reason="Invalid authentication token")
return
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(),
})
except WebSocketDisconnect:
await ws_manager.disconnect(websocket)
except Exception as e:
logger.error("WebSocket error: %s", e)
await ws_manager.disconnect(websocket)

View File

@@ -0,0 +1,169 @@
"""Script execution API endpoints."""
import asyncio
import logging
import subprocess
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import settings
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
logger = logging.getLogger(__name__)
class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional arguments."""
args: list[str] = Field(default_factory=list, description="Additional arguments")
class ScriptExecuteResponse(BaseModel):
"""Response model for script execution."""
success: bool
script: str
exit_code: int | None = None
stdout: str = ""
stderr: str = ""
error: str | None = None
class ScriptInfo(BaseModel):
"""Information about an available script."""
name: str
label: str
description: str
icon: str | None = None
timeout: int
@router.get("/list")
async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
"""List all available scripts.
Returns:
List of available scripts with their descriptions
"""
return [
ScriptInfo(
name=name,
label=config.label or name.replace("_", " ").title(),
description=config.description,
icon=config.icon,
timeout=config.timeout,
)
for name, config in settings.scripts.items()
]
@router.post("/execute/{script_name}")
async def execute_script(
script_name: str,
request: ScriptExecuteRequest | None = None,
_: str = Depends(verify_token),
) -> ScriptExecuteResponse:
"""Execute a pre-defined script by name.
Args:
script_name: Name of the script to execute (must be defined in config)
request: Optional arguments to pass to the script
Returns:
Execution result including stdout, stderr, and exit code
"""
# Check if script exists
if script_name not in settings.scripts:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
)
script_config = settings.scripts[script_name]
args = request.args if request else []
logger.info(f"Executing script: {script_name}")
try:
# Build command
command = script_config.command
if args:
# Append arguments to command
command = f"{command} {' '.join(args)}"
# Execute in thread pool to not block
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: _run_script(
command=command,
timeout=script_config.timeout,
shell=script_config.shell,
working_dir=script_config.working_dir,
),
)
return ScriptExecuteResponse(
success=result["exit_code"] == 0,
script=script_name,
exit_code=result["exit_code"],
stdout=result["stdout"],
stderr=result["stderr"],
)
except Exception as e:
logger.error(f"Script execution error: {e}")
return ScriptExecuteResponse(
success=False,
script=script_name,
error=str(e),
)
def _run_script(
command: str,
timeout: int,
shell: bool,
working_dir: str | None,
) -> dict[str, Any]:
"""Run a script synchronously.
Args:
command: Command to execute
timeout: Timeout in seconds
shell: Whether to run in shell
working_dir: Working directory
Returns:
Dict with exit_code, stdout, stderr
"""
try:
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
capture_output=True,
text=True,
timeout=timeout,
)
return {
"exit_code": result.returncode,
"stdout": result.stdout[:10000], # Limit output size
"stderr": result.stderr[:10000],
}
except subprocess.TimeoutExpired:
return {
"exit_code": -1,
"stdout": "",
"stderr": f"Script timed out after {timeout} seconds",
}
except Exception as e:
return {
"exit_code": -1,
"stdout": "",
"stderr": str(e),
}