diff --git a/README.md b/README.md index d36500a..021cdb4 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,9 @@ All control endpoints require authentication and return `{"success": true}` on s | `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) | | `/api/media/mute` | POST | - | Toggle mute | | `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) | +| `/api/media/turn_on` | POST | - | Execute on_turn_on callback | +| `/api/media/turn_off` | POST | - | Execute on_turn_off callback | +| `/api/media/toggle` | POST | - | Execute on_toggle callback | ### Script Execution @@ -263,6 +266,95 @@ Script configuration options: | `working_dir` | No | Working directory for the command | | `shell` | No | Run in shell (default: true) | +### Configuring Callbacks + +Callbacks are optional commands executed after media actions. Add them in your `config.yaml`: + +```yaml +callbacks: + # Media control callbacks (run after successful action) + on_play: + command: "echo Play triggered" + timeout: 10 + shell: true + + on_pause: + command: "echo Pause triggered" + timeout: 10 + shell: true + + on_stop: + command: "echo Stop triggered" + timeout: 10 + shell: true + + on_next: + command: "echo Next track" + timeout: 10 + shell: true + + on_previous: + command: "echo Previous track" + timeout: 10 + shell: true + + on_volume: + command: "echo Volume changed" + timeout: 10 + shell: true + + on_mute: + command: "echo Mute toggled" + timeout: 10 + shell: true + + on_seek: + command: "echo Seek triggered" + timeout: 10 + shell: true + + # Turn on/off/toggle (callback-only actions, no default behavior) + on_turn_on: + command: "echo PC turned on" + timeout: 10 + shell: true + + on_turn_off: + command: "rundll32.exe user32.dll,LockWorkStation" + timeout: 5 + shell: true + + on_toggle: + command: "echo Toggle triggered" + timeout: 10 + shell: true +``` + +Available callbacks: + +| Callback | Triggered by | Description | +|----------|--------------|-------------| +| `on_play` | `/api/media/play` | After play succeeds | +| `on_pause` | `/api/media/pause` | After pause succeeds | +| `on_stop` | `/api/media/stop` | After stop succeeds | +| `on_next` | `/api/media/next` | After next track succeeds | +| `on_previous` | `/api/media/previous` | After previous track succeeds | +| `on_volume` | `/api/media/volume` | After volume change succeeds | +| `on_mute` | `/api/media/mute` | After mute toggle | +| `on_seek` | `/api/media/seek` | After seek succeeds | +| `on_turn_on` | `/api/media/turn_on` | Callback-only action | +| `on_turn_off` | `/api/media/turn_off` | Callback-only action | +| `on_toggle` | `/api/media/toggle` | Callback-only action | + +Callback configuration options: + +| Field | Required | Description | +|-------|----------|-------------| +| `command` | Yes | Command to execute | +| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) | +| `working_dir` | No | Working directory for the command | +| `shell` | No | Run in shell (default: true) | + ## Running as a Service ### Windows Task Scheduler (Recommended) diff --git a/config.example.yaml b/config.example.yaml index 48e3433..f3c2000 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -44,4 +44,64 @@ scripts: label: "Restart" description: "Restart the PC immediately" timeout: 10 + shell: true + +# Callback scripts (executed after media actions) +# All callbacks are optional - if not defined, the action runs without callback +callbacks: + # Media control callbacks (run after successful action) + on_play: + command: "echo Play triggered" + timeout: 10 + shell: true + + on_pause: + command: "echo Pause triggered" + timeout: 10 + shell: true + + on_stop: + command: "echo Stop triggered" + timeout: 10 + shell: true + + on_next: + command: "echo Next track" + timeout: 10 + shell: true + + on_previous: + command: "echo Previous track" + timeout: 10 + shell: true + + on_volume: + command: "echo Volume changed" + timeout: 10 + shell: true + + on_mute: + command: "echo Mute toggled" + timeout: 10 + shell: true + + on_seek: + command: "echo Seek triggered" + timeout: 10 + shell: true + + # Turn on/off/toggle (callback-only actions, no default behavior) + on_turn_on: + command: "echo Turn on callback" + timeout: 10 + shell: true + + on_turn_off: + command: "rundll32.exe user32.dll,LockWorkStation" + timeout: 5 + shell: true + + on_toggle: + command: "echo Toggle callback" + timeout: 10 shell: true \ No newline at end of file diff --git a/media_server/config.py b/media_server/config.py index 8b0b8ed..f00acda 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -10,6 +10,15 @@ from pydantic import BaseModel, Field from pydantic_settings import BaseSettings, SettingsConfigDict +class CallbackConfig(BaseModel): + """Configuration for a callback script (no label/description needed).""" + + command: str = Field(..., description="Command or script to execute") + timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) + working_dir: Optional[str] = Field(default=None, description="Working directory") + shell: bool = Field(default=True, description="Run command in shell") + + class ScriptConfig(BaseModel): """Configuration for a custom script.""" @@ -47,6 +56,12 @@ class Settings(BaseSettings): default=1.0, description="Media status poll interval in seconds" ) + # Audio device settings + audio_device: Optional[str] = Field( + default=None, + description="Audio device name to control (None = default device). Use /api/audio/devices to list available devices.", + ) + # Logging log_level: str = Field(default="INFO", description="Logging level") @@ -56,6 +71,12 @@ class Settings(BaseSettings): description="Custom scripts that can be executed via API", ) + # Callback scripts (executed by integration events, not shown in UI) + callbacks: dict[str, CallbackConfig] = Field( + default_factory=dict, + description="Callback scripts executed by integration events (on_turn_on, on_turn_off, on_toggle)", + ) + @classmethod def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings": """Load settings from a YAML configuration file.""" @@ -110,6 +131,9 @@ def generate_default_config(path: Optional[Path] = None) -> Path: "api_token": secrets.token_urlsafe(32), "poll_interval": 1.0, "log_level": "INFO", + # Audio device to control (use GET /api/audio/devices to list available devices) + # Set to null or remove to use default device + # "audio_device": "Speakers (Realtek", "scripts": { "example_script": { "command": "echo Hello from Media Server!", diff --git a/media_server/main.py b/media_server/main.py index bba298a..8154f12 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -11,7 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from . import __version__ from .config import settings, generate_default_config, get_config_dir -from .routes import health_router, media_router, scripts_router +from .routes import audio_router, health_router, media_router, scripts_router from .services import get_media_controller from .services.websocket_manager import ws_manager @@ -64,6 +64,7 @@ def create_app() -> FastAPI: ) # Register routers + app.include_router(audio_router) app.include_router(health_router) app.include_router(media_router) app.include_router(scripts_router) diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py index 0fd6e84..1fe7168 100644 --- a/media_server/routes/__init__.py +++ b/media_server/routes/__init__.py @@ -1,7 +1,8 @@ """API route modules.""" +from .audio import router as audio_router 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"] +__all__ = ["audio_router", "health_router", "media_router", "scripts_router"] diff --git a/media_server/routes/media.py b/media_server/routes/media.py index 618d0aa..6c4a766 100644 --- a/media_server/routes/media.py +++ b/media_server/routes/media.py @@ -1,5 +1,6 @@ """Media control API endpoints.""" +import asyncio import logging from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect @@ -17,6 +18,33 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/media", tags=["media"]) +async def _run_callback(callback_name: str) -> None: + """Run a callback if configured. Failures are logged but don't raise.""" + if not settings.callbacks or callback_name not in settings.callbacks: + return + + from .scripts import _run_script + + 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"], + ) + + @router.get("/status", response_model=MediaStatus) async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus: """Get current media playback status. @@ -42,6 +70,7 @@ async def play(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to start playback - no active media session", ) + await _run_callback("on_play") return {"success": True} @@ -59,6 +88,7 @@ async def pause(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to pause - no active media session", ) + await _run_callback("on_pause") return {"success": True} @@ -76,6 +106,7 @@ async def stop(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to stop - no active media session", ) + await _run_callback("on_stop") return {"success": True} @@ -93,6 +124,7 @@ async def next_track(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to skip - no active media session", ) + await _run_callback("on_next") return {"success": True} @@ -110,6 +142,7 @@ async def previous_track(_: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to go back - no active media session", ) + await _run_callback("on_previous") return {"success": True} @@ -132,6 +165,7 @@ async def set_volume( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to set volume", ) + await _run_callback("on_volume") return {"success": True, "volume": request.volume} @@ -144,6 +178,7 @@ async def toggle_mute(_: str = Depends(verify_token)) -> dict: """ controller = get_media_controller() muted = await controller.toggle_mute() + await _run_callback("on_mute") return {"success": True, "muted": muted} @@ -164,9 +199,43 @@ async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict: status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Failed to seek - no active media session or seek not supported", ) + await _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 + """ + await _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 + """ + await _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 + """ + await _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. diff --git a/media_server/routes/scripts.py b/media_server/routes/scripts.py index ae7d743..e0270ec 100644 --- a/media_server/routes/scripts.py +++ b/media_server/routes/scripts.py @@ -82,7 +82,6 @@ async def execute_script( 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 []