Add callbacks support for all media actions

- Add CallbackConfig model for callback scripts
- Add callbacks section to config for optional command execution
- Add turn_on/turn_off/toggle endpoints (callback-only)
- Add callbacks for all media actions (play, pause, stop, next, previous, volume, mute, seek)
- Update README with callbacks documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 03:44:18 +03:00
parent a6cb420eef
commit 1a1cfbaafb
7 changed files with 249 additions and 3 deletions

View File

@@ -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/volume` | POST | `{"volume": 75}` | Set volume (0-100) |
| `/api/media/mute` | POST | - | Toggle mute | | `/api/media/mute` | POST | - | Toggle mute |
| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) | | `/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 ### Script Execution
@@ -263,6 +266,95 @@ Script configuration options:
| `working_dir` | No | Working directory for the command | | `working_dir` | No | Working directory for the command |
| `shell` | No | Run in shell (default: true) | | `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 ## Running as a Service
### Windows Task Scheduler (Recommended) ### Windows Task Scheduler (Recommended)

View File

@@ -44,4 +44,64 @@ scripts:
label: "Restart" label: "Restart"
description: "Restart the PC immediately" description: "Restart the PC immediately"
timeout: 10 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 shell: true

View File

@@ -10,6 +10,15 @@ from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict 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): class ScriptConfig(BaseModel):
"""Configuration for a custom script.""" """Configuration for a custom script."""
@@ -47,6 +56,12 @@ class Settings(BaseSettings):
default=1.0, description="Media status poll interval in seconds" 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 # Logging
log_level: str = Field(default="INFO", description="Logging level") 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", 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 @classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings": def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file.""" """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), "api_token": secrets.token_urlsafe(32),
"poll_interval": 1.0, "poll_interval": 1.0,
"log_level": "INFO", "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": { "scripts": {
"example_script": { "example_script": {
"command": "echo Hello from Media Server!", "command": "echo Hello from Media Server!",

View File

@@ -11,7 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
from . import __version__ from . import __version__
from .config import settings, generate_default_config, get_config_dir 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 import get_media_controller
from .services.websocket_manager import ws_manager from .services.websocket_manager import ws_manager
@@ -64,6 +64,7 @@ def create_app() -> FastAPI:
) )
# Register routers # Register routers
app.include_router(audio_router)
app.include_router(health_router) app.include_router(health_router)
app.include_router(media_router) app.include_router(media_router)
app.include_router(scripts_router) app.include_router(scripts_router)

View File

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

View File

@@ -1,5 +1,6 @@
"""Media control API endpoints.""" """Media control API endpoints."""
import asyncio
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
@@ -17,6 +18,33 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"]) 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) @router.get("/status", response_model=MediaStatus)
async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus: async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus:
"""Get current media playback status. """Get current media playback status.
@@ -42,6 +70,7 @@ async def play(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to start playback - no active media session", detail="Failed to start playback - no active media session",
) )
await _run_callback("on_play")
return {"success": True} return {"success": True}
@@ -59,6 +88,7 @@ async def pause(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to pause - no active media session", detail="Failed to pause - no active media session",
) )
await _run_callback("on_pause")
return {"success": True} return {"success": True}
@@ -76,6 +106,7 @@ async def stop(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to stop - no active media session", detail="Failed to stop - no active media session",
) )
await _run_callback("on_stop")
return {"success": True} return {"success": True}
@@ -93,6 +124,7 @@ async def next_track(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to skip - no active media session", detail="Failed to skip - no active media session",
) )
await _run_callback("on_next")
return {"success": True} return {"success": True}
@@ -110,6 +142,7 @@ async def previous_track(_: str = Depends(verify_token)) -> dict:
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to go back - no active media session", detail="Failed to go back - no active media session",
) )
await _run_callback("on_previous")
return {"success": True} return {"success": True}
@@ -132,6 +165,7 @@ async def set_volume(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to set volume", detail="Failed to set volume",
) )
await _run_callback("on_volume")
return {"success": True, "volume": request.volume} return {"success": True, "volume": request.volume}
@@ -144,6 +178,7 @@ async def toggle_mute(_: str = Depends(verify_token)) -> dict:
""" """
controller = get_media_controller() controller = get_media_controller()
muted = await controller.toggle_mute() muted = await controller.toggle_mute()
await _run_callback("on_mute")
return {"success": True, "muted": muted} 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, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to seek - no active media session or seek not supported", detail="Failed to seek - no active media session or seek not supported",
) )
await _run_callback("on_seek")
return {"success": True, "position": request.position} 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") @router.get("/artwork")
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response: async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
"""Get the current album artwork. """Get the current album artwork.

View File

@@ -82,7 +82,6 @@ async def execute_script(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.", detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
) )
script_config = settings.scripts[script_name] script_config = settings.scripts[script_name]
args = request.args if request else [] args = request.args if request else []