Compare commits
4 Commits
v1.0.0
...
71a0a6e6d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 71a0a6e6d1 | |||
| 5342cffac7 | |||
| a0d138bb93 | |||
| 1a1cfbaafb |
190
README.md
190
README.md
@@ -4,14 +4,58 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
|
||||
|
||||
## Features
|
||||
|
||||
- **Built-in Web UI** for real-time media control and monitoring
|
||||
- Control any media player via system-wide media transport controls
|
||||
- Play/Pause/Stop/Next/Previous track
|
||||
- Volume control and mute
|
||||
- Seek within tracks
|
||||
- Get current track info (title, artist, album, artwork)
|
||||
- WebSocket support for real-time updates
|
||||
- Token-based authentication
|
||||
- Cross-platform support
|
||||
|
||||
## Web UI
|
||||
|
||||
The media server includes a built-in web interface for controlling and monitoring media playback.
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time status updates** via WebSocket connection
|
||||
- **Album artwork display** with automatic updates
|
||||
- **Playback controls** - Play, pause, next, previous
|
||||
- **Volume control** with mute toggle
|
||||
- **Seekable progress bar** - Click to jump to any position
|
||||
- **Connection status indicator** - Know when you're connected
|
||||
- **Token authentication** - Saved in browser localStorage
|
||||
- **Responsive design** - Works on desktop and mobile
|
||||
- **Dark theme** - Easy on the eyes
|
||||
|
||||
### Accessing the Web UI
|
||||
|
||||
1. Start the media server:
|
||||
```bash
|
||||
python -m media_server.main
|
||||
```
|
||||
|
||||
2. Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:8765/
|
||||
```
|
||||
|
||||
3. Enter your API token when prompted (get it with `media-server --show-token`)
|
||||
|
||||
4. Start playing media in any supported player and watch the UI update in real-time!
|
||||
|
||||
### Remote Access
|
||||
|
||||
To access the Web UI from other devices on your network:
|
||||
|
||||
1. Find your computer's IP address (e.g., `192.168.1.100`)
|
||||
2. Navigate to `http://192.168.1.100:8765/` from any device on the same network
|
||||
3. Enter your API token
|
||||
|
||||
**Security Note:** For remote access over the internet, use a reverse proxy with HTTPS (nginx, Caddy) to encrypt traffic.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
@@ -71,13 +115,17 @@ Requires Termux and Termux:API apps from F-Droid.
|
||||
python -m media_server.main
|
||||
```
|
||||
|
||||
4. Test the connection:
|
||||
```bash
|
||||
curl http://localhost:8765/api/health
|
||||
```
|
||||
4. **Open the Web UI** (recommended):
|
||||
- Navigate to `http://localhost:8765/` in your browser
|
||||
- Enter your API token from step 2
|
||||
- Start playing media and control it from the web interface!
|
||||
|
||||
5. Test with authentication:
|
||||
5. Or test via API:
|
||||
```bash
|
||||
# Health check (no auth required)
|
||||
curl http://localhost:8765/api/health
|
||||
|
||||
# Get media status
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
|
||||
```
|
||||
|
||||
@@ -92,11 +140,46 @@ Configuration file locations:
|
||||
```yaml
|
||||
host: 0.0.0.0
|
||||
port: 8765
|
||||
api_token: your-secret-token-here
|
||||
|
||||
# API Tokens - Multiple tokens with labels for client identification
|
||||
api_tokens:
|
||||
home_assistant: "your-home-assistant-token-here"
|
||||
mobile: "your-mobile-app-token-here"
|
||||
web_ui: "your-web-ui-token-here"
|
||||
|
||||
poll_interval: 1.0
|
||||
log_level: INFO
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
The media server supports multiple API tokens with friendly labels. This allows you to:
|
||||
- Issue different tokens for different clients (Home Assistant, mobile apps, web UI, etc.)
|
||||
- Identify which client is making requests in the server logs
|
||||
- Revoke individual tokens without affecting other clients
|
||||
|
||||
**Token labels** appear in all server logs, making it easy to track and debug client connections:
|
||||
|
||||
```
|
||||
2026-02-06 03:36:20,806 - media_server.services.websocket_manager - [home_assistant] - INFO - WebSocket client connected
|
||||
2026-02-06 03:28:24,258 - media_server.routes.scripts - [mobile] - INFO - Executing script: lock_screen
|
||||
```
|
||||
|
||||
**Viewing your tokens:**
|
||||
```bash
|
||||
python -m media_server.main --show-token
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Config directory: C:\Users\...\AppData\Roaming\media-server
|
||||
|
||||
API Tokens:
|
||||
home_assistant B04zhGDjnxH6LIwxL3VOT0F4qORwaipD7LoDyeAG4EU
|
||||
mobile xyz123...
|
||||
web_ui abc456...
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`):
|
||||
@@ -104,10 +187,11 @@ All settings can be overridden with environment variables (prefix: `MEDIA_SERVER
|
||||
```bash
|
||||
export MEDIA_SERVER_HOST=0.0.0.0
|
||||
export MEDIA_SERVER_PORT=8765
|
||||
export MEDIA_SERVER_API_TOKEN=your-token
|
||||
export MEDIA_SERVER_LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
**Note:** For multi-token configuration, use the config.yaml file. Environment variables only support single-token mode.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Health Check
|
||||
@@ -164,6 +248,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 +350,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)
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
# Copy this file to config.yaml and customize as needed.
|
||||
# A secure token will be auto-generated on first run if not specified.
|
||||
|
||||
# API Token (generate a secure random token)
|
||||
api_token: "your-secure-token-here"
|
||||
# API Tokens - Multiple tokens with friendly labels
|
||||
# This allows you to identify which client is making requests in the logs
|
||||
api_tokens:
|
||||
home_assistant: "your-home-assistant-token-here"
|
||||
mobile: "your-mobile-app-token-here"
|
||||
web_ui: "your-web-ui-token-here"
|
||||
|
||||
# Server settings
|
||||
host: "0.0.0.0"
|
||||
@@ -45,3 +49,63 @@ scripts:
|
||||
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
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Authentication middleware and utilities."""
|
||||
|
||||
import secrets
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Request, status
|
||||
@@ -9,6 +11,24 @@ from .config import settings
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Context variable to store current request's token label
|
||||
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
||||
|
||||
|
||||
def get_token_label(token: str) -> Optional[str]:
|
||||
"""Get the label for a token. Returns None if token is invalid.
|
||||
|
||||
Args:
|
||||
token: The token to look up
|
||||
|
||||
Returns:
|
||||
The label for the token, or None if invalid
|
||||
"""
|
||||
for label, stored_token in settings.api_tokens.items():
|
||||
if secrets.compare_digest(stored_token, token):
|
||||
return label
|
||||
return None
|
||||
|
||||
|
||||
async def verify_token(
|
||||
request: Request,
|
||||
@@ -21,7 +41,7 @@ async def verify_token(
|
||||
credentials: The bearer token credentials
|
||||
|
||||
Returns:
|
||||
The validated token
|
||||
The token label
|
||||
|
||||
Raises:
|
||||
HTTPException: If the token is missing or invalid
|
||||
@@ -33,14 +53,17 @@ async def verify_token(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if credentials.credentials != settings.api_token:
|
||||
label = get_token_label(credentials.credentials)
|
||||
if label is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return credentials.credentials
|
||||
# Set label in context for logging
|
||||
token_label_var.set(label)
|
||||
return label
|
||||
|
||||
|
||||
class TokenAuth:
|
||||
@@ -54,7 +77,7 @@ class TokenAuth:
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str | None:
|
||||
"""Verify the token and return it or raise an exception."""
|
||||
"""Verify the token and return the label or raise an exception."""
|
||||
if credentials is None:
|
||||
if self.auto_error:
|
||||
raise HTTPException(
|
||||
@@ -64,7 +87,8 @@ class TokenAuth:
|
||||
)
|
||||
return None
|
||||
|
||||
if credentials.credentials != settings.api_token:
|
||||
label = get_token_label(credentials.credentials)
|
||||
if label is None:
|
||||
if self.auto_error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -73,7 +97,9 @@ class TokenAuth:
|
||||
)
|
||||
return None
|
||||
|
||||
return credentials.credentials
|
||||
# Set label in context for logging
|
||||
token_label_var.set(label)
|
||||
return label
|
||||
|
||||
|
||||
async def verify_token_or_query(
|
||||
@@ -89,23 +115,28 @@ async def verify_token_or_query(
|
||||
token: Token from query parameter
|
||||
|
||||
Returns:
|
||||
The validated token
|
||||
The token label
|
||||
|
||||
Raises:
|
||||
HTTPException: If the token is missing or invalid
|
||||
"""
|
||||
label = None
|
||||
|
||||
# Try header first
|
||||
if credentials is not None:
|
||||
if credentials.credentials == settings.api_token:
|
||||
return credentials.credentials
|
||||
label = get_token_label(credentials.credentials)
|
||||
|
||||
# Try query parameter
|
||||
if token is not None:
|
||||
if token == settings.api_token:
|
||||
return token
|
||||
if label is None and token is not None:
|
||||
label = get_token_label(token)
|
||||
|
||||
if label is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing or invalid authentication token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Set label in context for logging
|
||||
token_label_var.set(label)
|
||||
return label
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -37,9 +46,9 @@ class Settings(BaseSettings):
|
||||
port: int = Field(default=8765, description="Server port")
|
||||
|
||||
# Authentication
|
||||
api_token: str = Field(
|
||||
default_factory=lambda: secrets.token_urlsafe(32),
|
||||
description="API authentication token",
|
||||
api_tokens: dict[str, str] = Field(
|
||||
default_factory=lambda: {"default": secrets.token_urlsafe(32)},
|
||||
description="Named API tokens for access control (label: token pairs)",
|
||||
)
|
||||
|
||||
# Media controller settings
|
||||
@@ -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."""
|
||||
@@ -107,9 +128,14 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
config = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8765,
|
||||
"api_token": secrets.token_urlsafe(32),
|
||||
"api_tokens": {
|
||||
"default": 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!",
|
||||
|
||||
@@ -4,24 +4,41 @@ import argparse
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__
|
||||
from .auth import get_token_label, token_label_var
|
||||
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
|
||||
|
||||
|
||||
class TokenLabelFilter(logging.Filter):
|
||||
"""Add token label to log records."""
|
||||
|
||||
def filter(self, record):
|
||||
record.token_label = token_label_var.get("unknown")
|
||||
return True
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Configure application logging."""
|
||||
"""Configure application logging with token labels."""
|
||||
# Create filter and handler
|
||||
token_filter = TokenLabelFilter()
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.addFilter(token_filter)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s",
|
||||
handlers=[handler],
|
||||
)
|
||||
|
||||
|
||||
@@ -31,7 +48,10 @@ async def lifespan(app: FastAPI):
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||
logger.info(f"API Token: {settings.api_token[:8]}...")
|
||||
|
||||
# Log all configured tokens
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
|
||||
# Start WebSocket status monitor
|
||||
controller = get_media_controller()
|
||||
@@ -63,11 +83,47 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add token logging middleware
|
||||
@app.middleware("http")
|
||||
async def token_logging_middleware(request: Request, call_next):
|
||||
"""Extract token label and set in context for logging."""
|
||||
token_label = "unknown"
|
||||
|
||||
# Try Authorization header
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
|
||||
# Try query parameter (for artwork endpoint)
|
||||
elif "token" in request.query_params:
|
||||
token = request.query_params["token"]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
|
||||
token_label_var.set(token_label)
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Register routers
|
||||
app.include_router(audio_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(media_router)
|
||||
app.include_router(scripts_router)
|
||||
|
||||
# Mount static files and serve UI at root
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def serve_ui():
|
||||
"""Serve the Web UI."""
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -108,8 +164,10 @@ def main():
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
print(f"API Token: {settings.api_token}")
|
||||
print(f"Config directory: {get_config_dir()}")
|
||||
print(f"\nAPI Tokens:")
|
||||
for label, token in settings.api_tokens.items():
|
||||
print(f" {label:20} {token}")
|
||||
return
|
||||
|
||||
uvicorn.run(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -213,10 +282,16 @@ async def websocket_endpoint(
|
||||
- {"type": "get_status"} - Request current status
|
||||
"""
|
||||
# Verify token
|
||||
if token != settings.api_token:
|
||||
from ..auth import get_token_label, token_label_var
|
||||
|
||||
label = get_token_label(token) if token else None
|
||||
if label is None:
|
||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||
return
|
||||
|
||||
# Set label in context for logging
|
||||
token_label_var.set(label)
|
||||
|
||||
await ws_manager.connect(websocket)
|
||||
|
||||
try:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
1013
media_server/static/index.html
Normal file
1013
media_server/static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user