Compare commits

..

7 Commits

Author SHA1 Message Date
9bbb8e1bd7 Add internationalization (i18n) support with English and Russian locales
- Add translation JSON files (en.json, ru.json) with 110+ strings each
- Implement locale auto-detection from browser settings
- Add locale toggle button (EN/RU) with localStorage persistence
- Translate all user-facing text: auth, player, scripts, callbacks
- Fix dynamic content translation on locale switch (playback state, track title)
- Add comprehensive i18n documentation to CLAUDE.md
- Follow existing theme toggle pattern for consistency

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 04:27:50 +03:00
a0af855846 Add callback management API/UI and theme support
- Add callback CRUD endpoints (create, update, delete, list)
- Add callback management UI with all 11 callback events support
- Add light/dark theme switcher with localStorage persistence
- Improve button styling (wider buttons, simplified text)
- Extend ConfigManager with callback operations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 04:11:57 +03:00
d7c5994e56 Add runtime script management with Home Assistant integration
Features:
- Runtime script CRUD operations (create, update, delete)
- Thread-safe ConfigManager for YAML updates
- WebSocket notifications for script changes
- Web UI script management interface with full CRUD
- Home Assistant auto-reload on script changes
- Client-side position interpolation for smooth playback
- Include command field in script list API response

Technical improvements:
- Added broadcast_scripts_changed() to WebSocket manager
- Enhanced HA integration to handle scripts_changed messages
- Implemented smooth position updates in Web UI (100ms interval)
- Thread-safe configuration updates with file locking

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 03:53:23 +03:00
71a0a6e6d1 Add multi-token authentication with client labels
- Replace single api_token with api_tokens dict (label: token pairs)
- Add context-aware logging to track which client made each request
- Implement token label lookup with secure comparison
- Add logging middleware to inject token labels into request context
- Update logging format to display [label] in all log messages
- Fix WebSocket authentication to use new multi-token system
- Update CLI --show-token to display all tokens with labels
- Update config generation to use api_tokens format
- Update README with multi-token documentation
- Update config.example.yaml with multiple token examples

Benefits:
- Easy identification of clients in logs (Home Assistant, mobile, web UI, etc.)
- Per-client token management and revocation
- Better security and auditability

Example log output:
2026-02-06 03:36:20,806 - [home_assistant] - WebSocket client connected

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 03:37:35 +03:00
5342cffac7 Add script execution to Web UI
- Add "Quick Actions" section to display configured scripts
- Load scripts from /api/scripts/list on connection
- Display scripts in responsive grid layout
- Execute scripts with single click via /api/scripts/execute
- Show toast notifications for execution feedback
- Visual feedback during script execution
- Auto-hide section if no scripts configured

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 03:28:54 +03:00
a0d138bb93 Add built-in Web UI for media control and monitoring
- Add static file serving to FastAPI application
- Create responsive web interface with real-time updates
- Features:
  - Real-time status updates via WebSocket
  - Album artwork display with automatic updates
  - Playback controls (play, pause, next, previous)
  - Volume control with mute toggle
  - Seekable progress bar
  - Token authentication with localStorage persistence
  - Dark theme and responsive design
  - Auto-reconnect WebSocket support
- Update README with Web UI documentation
- Zero dependencies (vanilla HTML/CSS/JavaScript)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 03:25:08 +03:00
1a1cfbaafb 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>
2026-02-05 03:44:18 +03:00
19 changed files with 3568 additions and 66 deletions

View File

@@ -34,6 +34,43 @@ The API token is generated on first run and displayed in the console output.
Default port: `8765` Default port: `8765`
## Internationalization (i18n)
The Web UI supports multiple languages with translations stored in separate JSON files.
### Locale Files
Translation files are located in:
- `media_server/static/locales/en.json` - English (default)
- `media_server/static/locales/ru.json` - Russian
### Maintaining Translations
**IMPORTANT:** When adding or modifying user-facing text in the Web UI:
1. **Update all locale files** - Add or update the translation key in **both** `en.json` and `ru.json`
2. **Use consistent keys** - Follow the existing key naming pattern (e.g., `section.element`, `scripts.button.save`)
3. **Test both locales** - Verify translations appear correctly by switching between EN/RU
### Adding New Text
When adding new UI elements:
1. Add the English text to `static/locales/en.json`
2. Add the Russian translation to `static/locales/ru.json`
3. In HTML: use `data-i18n="key.name"` for text content
4. In HTML: use `data-i18n-placeholder="key.name"` for input placeholders
5. In HTML: use `data-i18n-title="key.name"` for title attributes
6. In JavaScript: use `t('key.name')` or `t('key.name', {param: value})` for dynamic text
### Adding New Locales
To add support for a new language:
1. Create `media_server/static/locales/{lang_code}.json` (copy from `en.json`)
2. Translate all strings to the new language
3. Add the language code to `supportedLocales` array in `index.html`
## Versioning ## Versioning
Version is tracked in two files that must be kept in sync: Version is tracked in two files that must be kept in sync:

190
README.md
View File

@@ -4,14 +4,58 @@ A REST API server for controlling system media playback on Windows, Linux, macOS
## Features ## Features
- **Built-in Web UI** for real-time media control and monitoring
- Control any media player via system-wide media transport controls - Control any media player via system-wide media transport controls
- Play/Pause/Stop/Next/Previous track - Play/Pause/Stop/Next/Previous track
- Volume control and mute - Volume control and mute
- Seek within tracks - Seek within tracks
- Get current track info (title, artist, album, artwork) - Get current track info (title, artist, album, artwork)
- WebSocket support for real-time updates
- Token-based authentication - Token-based authentication
- Cross-platform support - 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 ## Requirements
- Python 3.10+ - Python 3.10+
@@ -71,13 +115,17 @@ Requires Termux and Termux:API apps from F-Droid.
python -m media_server.main python -m media_server.main
``` ```
4. Test the connection: 4. **Open the Web UI** (recommended):
```bash - Navigate to `http://localhost:8765/` in your browser
curl http://localhost:8765/api/health - 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 ```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 curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
``` ```
@@ -92,11 +140,46 @@ Configuration file locations:
```yaml ```yaml
host: 0.0.0.0 host: 0.0.0.0
port: 8765 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 poll_interval: 1.0
log_level: INFO 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 ### Environment Variables
All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`): 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 ```bash
export MEDIA_SERVER_HOST=0.0.0.0 export MEDIA_SERVER_HOST=0.0.0.0
export MEDIA_SERVER_PORT=8765 export MEDIA_SERVER_PORT=8765
export MEDIA_SERVER_API_TOKEN=your-token
export MEDIA_SERVER_LOG_LEVEL=DEBUG 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 ## API Reference
### Health Check ### 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/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 +350,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

@@ -2,8 +2,12 @@
# Copy this file to config.yaml and customize as needed. # Copy this file to config.yaml and customize as needed.
# A secure token will be auto-generated on first run if not specified. # A secure token will be auto-generated on first run if not specified.
# API Token (generate a secure random token) # API Tokens - Multiple tokens with friendly labels
api_token: "your-secure-token-here" # 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 # Server settings
host: "0.0.0.0" host: "0.0.0.0"
@@ -45,3 +49,63 @@ scripts:
description: "Restart the PC immediately" description: "Restart the PC immediately"
timeout: 10 timeout: 10
shell: true 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

View File

@@ -1,5 +1,7 @@
"""Authentication middleware and utilities.""" """Authentication middleware and utilities."""
import secrets
from contextvars import ContextVar
from typing import Optional from typing import Optional
from fastapi import Depends, HTTPException, Query, Request, status from fastapi import Depends, HTTPException, Query, Request, status
@@ -9,6 +11,24 @@ from .config import settings
security = HTTPBearer(auto_error=False) 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( async def verify_token(
request: Request, request: Request,
@@ -21,7 +41,7 @@ async def verify_token(
credentials: The bearer token credentials credentials: The bearer token credentials
Returns: Returns:
The validated token The token label
Raises: Raises:
HTTPException: If the token is missing or invalid HTTPException: If the token is missing or invalid
@@ -33,14 +53,17 @@ async def verify_token(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
if credentials.credentials != settings.api_token: label = get_token_label(credentials.credentials)
if label is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token", detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
return credentials.credentials # Set label in context for logging
token_label_var.set(label)
return label
class TokenAuth: class TokenAuth:
@@ -54,7 +77,7 @@ class TokenAuth:
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str | None: ) -> 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 credentials is None:
if self.auto_error: if self.auto_error:
raise HTTPException( raise HTTPException(
@@ -64,7 +87,8 @@ class TokenAuth:
) )
return None return None
if credentials.credentials != settings.api_token: label = get_token_label(credentials.credentials)
if label is None:
if self.auto_error: if self.auto_error:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -73,7 +97,9 @@ class TokenAuth:
) )
return None return None
return credentials.credentials # Set label in context for logging
token_label_var.set(label)
return label
async def verify_token_or_query( async def verify_token_or_query(
@@ -89,23 +115,28 @@ async def verify_token_or_query(
token: Token from query parameter token: Token from query parameter
Returns: Returns:
The validated token The token label
Raises: Raises:
HTTPException: If the token is missing or invalid HTTPException: If the token is missing or invalid
""" """
label = None
# Try header first # Try header first
if credentials is not None: if credentials is not None:
if credentials.credentials == settings.api_token: label = get_token_label(credentials.credentials)
return credentials.credentials
# Try query parameter # Try query parameter
if token is not None: if label is None and token is not None:
if token == settings.api_token: label = get_token_label(token)
return token
if label is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid authentication token", detail="Missing or invalid authentication token",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Set label in context for logging
token_label_var.set(label)
return label

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."""
@@ -37,9 +46,9 @@ class Settings(BaseSettings):
port: int = Field(default=8765, description="Server port") port: int = Field(default=8765, description="Server port")
# Authentication # Authentication
api_token: str = Field( api_tokens: dict[str, str] = Field(
default_factory=lambda: secrets.token_urlsafe(32), default_factory=lambda: {"default": secrets.token_urlsafe(32)},
description="API authentication token", description="Named API tokens for access control (label: token pairs)",
) )
# Media controller settings # Media controller settings
@@ -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."""
@@ -107,9 +128,14 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
config = { config = {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"api_token": secrets.token_urlsafe(32), "api_tokens": {
"default": 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

@@ -0,0 +1,284 @@
"""Thread-safe configuration file manager for runtime script updates."""
import logging
import os
import threading
from pathlib import Path
from typing import Optional
import yaml
from .config import CallbackConfig, ScriptConfig, settings
logger = logging.getLogger(__name__)
class ConfigManager:
"""Thread-safe configuration file manager."""
def __init__(self, config_path: Optional[Path] = None):
"""Initialize the config manager.
Args:
config_path: Path to config file. If None, will search standard locations.
"""
self._lock = threading.Lock()
self._config_path = config_path or self._find_config_path()
logger.info(f"ConfigManager initialized with path: {self._config_path}")
def _find_config_path(self) -> Path:
"""Find the active config file path.
Returns:
Path to the config file.
Raises:
FileNotFoundError: If no config file is found.
"""
# Same search logic as Settings.load_from_yaml()
search_paths = [
Path("config.yaml"),
Path("config.yml"),
]
# Add platform-specific config directory
if os.name == "nt": # Windows
appdata = os.environ.get("APPDATA", "")
if appdata:
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
else: # Linux/Unix/macOS
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
search_paths.append(Path("/etc/media-server/config.yaml"))
for search_path in search_paths:
if search_path.exists():
return search_path
# If not found, use the default location
if os.name == "nt":
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
else:
default_path = Path.home() / ".config" / "media-server" / "config.yaml"
logger.warning(f"No config file found, using default path: {default_path}")
return default_path
def add_script(self, name: str, config: ScriptConfig) -> None:
"""Add a new script to config.
Args:
name: Script name (must be unique).
config: Script configuration.
Raises:
ValueError: If script already exists.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if script already exists
if "scripts" in data and name in data["scripts"]:
raise ValueError(f"Script '{name}' already exists")
# Add script
if "scripts" not in data:
data["scripts"] = {}
data["scripts"][name] = config.model_dump(exclude_none=True)
# Write YAML
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.scripts[name] = config
logger.info(f"Script '{name}' added to config")
def update_script(self, name: str, config: ScriptConfig) -> None:
"""Update an existing script.
Args:
name: Script name.
config: New script configuration.
Raises:
ValueError: If script does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if script exists
if "scripts" not in data or name not in data["scripts"]:
raise ValueError(f"Script '{name}' does not exist")
# Update script
data["scripts"][name] = config.model_dump(exclude_none=True)
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.scripts[name] = config
logger.info(f"Script '{name}' updated in config")
def delete_script(self, name: str) -> None:
"""Delete a script from config.
Args:
name: Script name.
Raises:
ValueError: If script does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if script exists
if "scripts" not in data or name not in data["scripts"]:
raise ValueError(f"Script '{name}' does not exist")
# Delete script
del data["scripts"][name]
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if name in settings.scripts:
del settings.scripts[name]
logger.info(f"Script '{name}' deleted from config")
def add_callback(self, name: str, config: CallbackConfig) -> None:
"""Add a new callback to config.
Args:
name: Callback name (must be unique).
config: Callback configuration.
Raises:
ValueError: If callback already exists.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
data = {}
else:
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if callback already exists
if "callbacks" in data and name in data["callbacks"]:
raise ValueError(f"Callback '{name}' already exists")
# Add callback
if "callbacks" not in data:
data["callbacks"] = {}
data["callbacks"][name] = config.model_dump(exclude_none=True)
# Write YAML
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.callbacks[name] = config
logger.info(f"Callback '{name}' added to config")
def update_callback(self, name: str, config: CallbackConfig) -> None:
"""Update an existing callback.
Args:
name: Callback name.
config: New callback configuration.
Raises:
ValueError: If callback does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if callback exists
if "callbacks" not in data or name not in data["callbacks"]:
raise ValueError(f"Callback '{name}' does not exist")
# Update callback
data["callbacks"][name] = config.model_dump(exclude_none=True)
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
settings.callbacks[name] = config
logger.info(f"Callback '{name}' updated in config")
def delete_callback(self, name: str) -> None:
"""Delete a callback from config.
Args:
name: Callback name.
Raises:
ValueError: If callback does not exist.
IOError: If config file cannot be written.
"""
with self._lock:
# Read YAML
if not self._config_path.exists():
raise ValueError(f"Config file not found: {self._config_path}")
with open(self._config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
# Check if callback exists
if "callbacks" not in data or name not in data["callbacks"]:
raise ValueError(f"Callback '{name}' does not exist")
# Delete callback
del data["callbacks"][name]
# Write YAML
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if name in settings.callbacks:
del settings.callbacks[name]
logger.info(f"Callback '{name}' deleted from config")
# Global config manager instance
config_manager = ConfigManager()

View File

@@ -4,24 +4,41 @@ import argparse
import logging import logging
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from . import __version__ from . import __version__
from .auth import get_token_label, token_label_var
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, callbacks_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
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(): 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( logging.basicConfig(
level=getattr(logging, settings.log_level.upper()), level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)], handlers=[handler],
) )
@@ -31,7 +48,10 @@ async def lifespan(app: FastAPI):
setup_logging() setup_logging()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Media Server starting on {settings.host}:{settings.port}") 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 # Start WebSocket status monitor
controller = get_media_controller() controller = get_media_controller()
@@ -63,11 +83,48 @@ def create_app() -> FastAPI:
allow_headers=["*"], 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 # Register routers
app.include_router(audio_router)
app.include_router(callbacks_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)
# 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 return app
@@ -108,8 +165,10 @@ def main():
return return
if args.show_token: if args.show_token:
print(f"API Token: {settings.api_token}")
print(f"Config directory: {get_config_dir()}") 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 return
uvicorn.run( uvicorn.run(

View File

@@ -1,7 +1,9 @@
"""API route modules.""" """API route modules."""
from .audio import router as audio_router
from .callbacks import router as callbacks_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", "callbacks_router", "health_router", "media_router", "scripts_router"]

View File

@@ -0,0 +1,28 @@
"""Audio device API endpoints."""
import logging
from fastapi import APIRouter, Depends
from ..auth import verify_token
from ..services import get_audio_devices
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/audio", tags=["audio"])
@router.get("/devices")
async def list_audio_devices(_: str = Depends(verify_token)) -> list[dict[str, str]]:
"""List available audio output devices.
Returns a list of audio devices with their IDs and friendly names.
Use the device name in the `audio_device` config option to control
a specific device instead of the default one.
Returns:
List of audio devices with id and name
"""
devices = get_audio_devices()
logger.debug("Found %d audio devices", len(devices))
return devices

View File

@@ -0,0 +1,214 @@
"""Callback management API endpoints."""
import logging
import re
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import CallbackConfig, settings
from ..config_manager import config_manager
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__)
class CallbackInfo(BaseModel):
"""Information about a configured callback."""
name: str
command: str
timeout: int
working_dir: str | None = None
shell: bool
class CallbackCreateRequest(BaseModel):
"""Request model for creating or updating a callback."""
command: str = Field(..., description="Command to execute", min_length=1)
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
def _validate_callback_name(name: str) -> None:
"""Validate callback name.
Args:
name: Callback name to validate.
Raises:
HTTPException: If name is invalid.
"""
# All available callback events
valid_names = {
"on_play",
"on_pause",
"on_stop",
"on_next",
"on_previous",
"on_volume",
"on_mute",
"on_seek",
"on_turn_on",
"on_turn_off",
"on_toggle",
}
if name not in valid_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Callback name must be one of: {', '.join(sorted(valid_names))}",
)
@router.get("/list")
async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
"""List all configured callbacks.
Returns:
List of configured callbacks.
"""
return [
CallbackInfo(
name=name,
command=config.command,
timeout=config.timeout,
working_dir=config.working_dir,
shell=config.shell,
)
for name, config in settings.callbacks.items()
]
@router.post("/create/{callback_name}")
async def create_callback(
callback_name: str,
request: CallbackCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new callback.
Args:
callback_name: Callback event name (on_turn_on, on_turn_off, on_toggle).
request: Callback configuration.
Returns:
Success response with callback name.
Raises:
HTTPException: If callback already exists or name is invalid.
"""
# Validate name
_validate_callback_name(callback_name)
# Check if callback already exists
if callback_name in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Callback '{callback_name}' already exists. Use PUT /api/callbacks/update/{callback_name} to update it.",
)
# Create callback config
callback_config = CallbackConfig(**request.model_dump())
# Add to config file and in-memory
try:
config_manager.add_callback(callback_name, callback_config)
except Exception as e:
logger.error(f"Failed to add callback '{callback_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add callback: {str(e)}",
)
logger.info(f"Callback '{callback_name}' created successfully")
return {"success": True, "callback": callback_name}
@router.put("/update/{callback_name}")
async def update_callback(
callback_name: str,
request: CallbackCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing callback.
Args:
callback_name: Callback event name.
request: Updated callback configuration.
Returns:
Success response with callback name.
Raises:
HTTPException: If callback does not exist.
"""
# Validate name
_validate_callback_name(callback_name)
# Check if callback exists
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Callback '{callback_name}' not found. Use POST /api/callbacks/create/{callback_name} to create it.",
)
# Create updated callback config
callback_config = CallbackConfig(**request.model_dump())
# Update config file and in-memory
try:
config_manager.update_callback(callback_name, callback_config)
except Exception as e:
logger.error(f"Failed to update callback '{callback_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update callback: {str(e)}",
)
logger.info(f"Callback '{callback_name}' updated successfully")
return {"success": True, "callback": callback_name}
@router.delete("/delete/{callback_name}")
async def delete_callback(
callback_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a callback.
Args:
callback_name: Callback event name.
Returns:
Success response with callback name.
Raises:
HTTPException: If callback does not exist.
"""
# Validate name
_validate_callback_name(callback_name)
# Check if callback exists
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Callback '{callback_name}' not found",
)
# Delete from config file and in-memory
try:
config_manager.delete_callback(callback_name)
except Exception as e:
logger.error(f"Failed to delete callback '{callback_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete callback: {str(e)}",
)
logger.info(f"Callback '{callback_name}' deleted successfully")
return {"success": True, "callback": callback_name}

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.
@@ -213,10 +282,16 @@ async def websocket_endpoint(
- {"type": "get_status"} - Request current status - {"type": "get_status"} - Request current status
""" """
# Verify token # 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") await websocket.close(code=4001, reason="Invalid authentication token")
return return
# Set label in context for logging
token_label_var.set(label)
await ws_manager.connect(websocket) await ws_manager.connect(websocket)
try: try:

View File

@@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import re
import subprocess import subprocess
from typing import Any from typing import Any
@@ -9,7 +10,9 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..auth import verify_token from ..auth import verify_token
from ..config import settings from ..config import ScriptConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"]) router = APIRouter(prefix="/api/scripts", tags=["scripts"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,6 +40,7 @@ class ScriptInfo(BaseModel):
name: str name: str
label: str label: str
command: str
description: str description: str
icon: str | None = None icon: str | None = None
timeout: int timeout: int
@@ -53,6 +57,7 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
ScriptInfo( ScriptInfo(
name=name, name=name,
label=config.label or name.replace("_", " ").title(), label=config.label or name.replace("_", " ").title(),
command=config.command,
description=config.description, description=config.description,
icon=config.icon, icon=config.icon,
timeout=config.timeout, timeout=config.timeout,
@@ -82,7 +87,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 []
@@ -167,3 +171,185 @@ def _run_script(
"stdout": "", "stdout": "",
"stderr": str(e), "stderr": str(e),
} }
# Script management endpoints
class ScriptCreateRequest(BaseModel):
"""Request model for creating or updating a script."""
command: str = Field(..., description="Command to execute", min_length=1)
label: str | None = Field(default=None, description="User-friendly label")
description: str = Field(default="", description="Script description")
icon: str | None = Field(default=None, description="Custom MDI icon (e.g., 'mdi:power')")
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
def _validate_script_name(name: str) -> None:
"""Validate script name.
Args:
name: Script name to validate.
Raises:
HTTPException: If name is invalid.
"""
if not name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name cannot be empty",
)
if not re.match(r"^[a-zA-Z0-9_]+$", name):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name must contain only alphanumeric characters and underscores",
)
if len(name) > 64:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Script name must be 64 characters or less",
)
@router.post("/create/{script_name}")
async def create_script(
script_name: str,
request: ScriptCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Create a new script.
Args:
script_name: Name for the new script (alphanumeric + underscore only).
request: Script configuration.
Returns:
Success response with script name.
Raises:
HTTPException: If script already exists or name is invalid.
"""
# Validate name
_validate_script_name(script_name)
# Check if script already exists
if script_name in settings.scripts:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
)
# Create script config
script_config = ScriptConfig(**request.model_dump())
# Add to config file and in-memory
try:
config_manager.add_script(script_name, script_config)
except Exception as e:
logger.error(f"Failed to add script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' created successfully")
return {"success": True, "script": script_name}
@router.put("/update/{script_name}")
async def update_script(
script_name: str,
request: ScriptCreateRequest,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Update an existing script.
Args:
script_name: Name of the script to update.
request: Updated script configuration.
Returns:
Success response with script name.
Raises:
HTTPException: If script does not exist.
"""
# Validate name
_validate_script_name(script_name)
# 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 POST /api/scripts/create/{script_name} to create it.",
)
# Create updated script config
script_config = ScriptConfig(**request.model_dump())
# Update config file and in-memory
try:
config_manager.update_script(script_name, script_config)
except Exception as e:
logger.error(f"Failed to update script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' updated successfully")
return {"success": True, "script": script_name}
@router.delete("/delete/{script_name}")
async def delete_script(
script_name: str,
_: str = Depends(verify_token),
) -> dict[str, Any]:
"""Delete a script.
Args:
script_name: Name of the script to delete.
Returns:
Success response with script name.
Raises:
HTTPException: If script does not exist.
"""
# Validate name
_validate_script_name(script_name)
# 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",
)
# Delete from config file and in-memory
try:
config_manager.delete_script(script_name)
except Exception as e:
logger.error(f"Failed to delete script '{script_name}': {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete script: {str(e)}",
)
# Notify WebSocket clients
await ws_manager.broadcast_scripts_changed()
logger.info(f"Script '{script_name}' deleted successfully")
return {"success": True, "script": script_name}

View File

@@ -1,6 +1,7 @@
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
# Get the project root directory (two levels up from this script) # Get the project root directory (two levels up from this script)
$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName $projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName
$action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot $action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot
$trigger = New-ScheduledTaskTrigger -AtStartup $trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest $principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest

View File

@@ -41,8 +41,9 @@ def get_media_controller() -> "MediaController":
if system == "Windows": if system == "Windows":
from .windows_media import WindowsMediaController from .windows_media import WindowsMediaController
from ..config import settings
_controller_instance = WindowsMediaController() _controller_instance = WindowsMediaController(audio_device=settings.audio_device)
elif system == "Linux": elif system == "Linux":
# Check if running on Android # Check if running on Android
if _is_android(): if _is_android():
@@ -72,4 +73,13 @@ def get_current_album_art() -> bytes | None:
return None return None
__all__ = ["get_media_controller", "get_current_album_art"] def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices (Windows only for now)."""
system = platform.system()
if system == "Windows":
from .windows_media import WindowsMediaController
return WindowsMediaController.get_audio_devices()
return []
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"]

View File

@@ -68,6 +68,12 @@ class ConnectionManager:
for ws in disconnected: for ws in disconnected:
await self.disconnect(ws) await self.disconnect(ws)
async def broadcast_scripts_changed(self) -> None:
"""Notify all connected clients that scripts have changed."""
message = {"type": "scripts_changed", "data": {}}
await self.broadcast(message)
logger.info("Broadcast sent: scripts_changed")
def status_changed( def status_changed(
self, old: dict[str, Any] | None, new: dict[str, Any] self, old: dict[str, Any] | None, new: dict[str, Any]
) -> bool: ) -> bool:

View File

@@ -54,41 +54,100 @@ except ImportError:
# Volume control imports # Volume control imports
PYCAW_AVAILABLE = False PYCAW_AVAILABLE = False
_volume_control = None _volume_control = None
_configured_device_name: str | None = None
try: try:
from ctypes import cast, POINTER from ctypes import cast, POINTER
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
def _init_volume_control(): import warnings
"""Initialize volume control interface.""" # Suppress pycaw warnings about missing device properties
global _volume_control warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
if _volume_control is not None:
return _volume_control def _get_all_audio_devices() -> list[dict[str, str]]:
"""Get list of all audio output devices."""
devices = []
try: try:
devices = AudioUtilities.GetSpeakers() # Use pycaw's GetAllDevices which handles property retrieval
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) all_devices = AudioUtilities.GetAllDevices()
_volume_control = cast(interface, POINTER(IAudioEndpointVolume)) for device in all_devices:
return _volume_control # Only include render (output) devices with valid names
except AttributeError: # Render devices have IDs starting with {0.0.0
# Try accessing the underlying device if device.FriendlyName and device.id and device.id.startswith("{0.0.0"):
devices.append({
"id": device.id,
"name": device.FriendlyName,
})
except Exception as e:
logger.error(f"Error enumerating audio devices: {e}")
return devices
def _find_device_by_name(device_name: str):
"""Find an audio device by its friendly name (partial match).
Returns the AudioDevice wrapper for the matched device.
"""
try: try:
devices = AudioUtilities.GetSpeakers() # Get all devices and find matching one
if hasattr(devices, '_dev'): all_devices = AudioUtilities.GetAllDevices()
interface = devices._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) for device in all_devices:
if device.FriendlyName and device_name.lower() in device.FriendlyName.lower():
logger.info(f"Found audio device: {device.FriendlyName}")
return device
except Exception as e:
logger.error(f"Error finding device by name: {e}")
return None
def _init_volume_control(device_name: str | None = None):
"""Initialize volume control interface.
Args:
device_name: Name of the audio device to control (partial match).
If None, uses the default audio device.
"""
global _volume_control, _configured_device_name
if _volume_control is not None and device_name == _configured_device_name:
return _volume_control
_configured_device_name = device_name
try:
if device_name:
# Find specific device by name
device = _find_device_by_name(device_name)
if device is None:
logger.warning(f"Audio device '{device_name}' not found, using default")
device = AudioUtilities.GetSpeakers()
else:
# Use default device
device = AudioUtilities.GetSpeakers()
if hasattr(device, 'Activate'):
interface = device.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
elif hasattr(device, '_dev'):
interface = device._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
else:
logger.warning("Could not activate audio device")
return None
_volume_control = cast(interface, POINTER(IAudioEndpointVolume)) _volume_control = cast(interface, POINTER(IAudioEndpointVolume))
return _volume_control return _volume_control
except Exception as e: except Exception as e:
logger.debug(f"Volume control init failed: {e}") logger.error(f"Volume control init error: {e}")
except Exception as e:
logger.debug(f"Volume control init error: {e}")
return None return None
PYCAW_AVAILABLE = True PYCAW_AVAILABLE = True
except ImportError as e: except ImportError as e:
logger.warning(f"pycaw not available: {e}") logger.warning(f"pycaw not available: {e}")
def _init_volume_control(): def _get_all_audio_devices() -> list[dict[str, str]]:
return []
def _find_device_by_name(device_name: str):
return None
def _init_volume_control(device_name: str | None = None):
return None return None
WINDOWS_AVAILABLE = WINSDK_AVAILABLE WINDOWS_AVAILABLE = WINSDK_AVAILABLE
@@ -427,25 +486,38 @@ def _sync_seek(position: float) -> bool:
class WindowsMediaController(MediaController): class WindowsMediaController(MediaController):
"""Media controller for Windows using WinRT and pycaw.""" """Media controller for Windows using WinRT and pycaw."""
def __init__(self): def __init__(self, audio_device: str | None = None):
"""Initialize the Windows media controller.
Args:
audio_device: Name of the audio device to control (partial match).
If None, uses the default audio device.
"""
if not WINDOWS_AVAILABLE: if not WINDOWS_AVAILABLE:
raise RuntimeError( raise RuntimeError(
"Windows media control requires winsdk, pycaw, and comtypes packages" "Windows media control requires winsdk, pycaw, and comtypes packages"
) )
self._volume_interface = None self._volume_interface = None
self._volume_init_attempted = False self._volume_init_attempted = False
self._audio_device = audio_device
def _get_volume_interface(self): def _get_volume_interface(self):
"""Get the audio endpoint volume interface.""" """Get the audio endpoint volume interface."""
if not self._volume_init_attempted: if not self._volume_init_attempted:
self._volume_init_attempted = True self._volume_init_attempted = True
self._volume_interface = _init_volume_control() self._volume_interface = _init_volume_control(self._audio_device)
if self._volume_interface: if self._volume_interface:
logger.info("Volume control initialized successfully") device_info = f" (device: {self._audio_device})" if self._audio_device else " (default device)"
logger.info(f"Volume control initialized successfully{device_info}")
else: else:
logger.warning("Volume control not available") logger.warning("Volume control not available")
return self._volume_interface return self._volume_interface
@staticmethod
def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices."""
return _get_all_audio_devices()
async def get_status(self) -> MediaStatus: async def get_status(self) -> MediaStatus:
"""Get current media playback status.""" """Get current media playback status."""
status = MediaStatus() status = MediaStatus()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
{
"app.title": "Media Server",
"auth.message": "Enter your API token to connect to the media server.",
"auth.placeholder": "Enter API Token",
"auth.connect": "Connect",
"auth.help": "To get your token, run:",
"auth.logout": "Logout",
"auth.logout.title": "Clear saved token",
"auth.invalid": "Invalid token. Please try again.",
"auth.cleared": "Token cleared. Please enter a new token.",
"auth.required": "Please enter a token",
"player.theme": "Toggle theme",
"player.locale": "Change language",
"player.previous": "Previous",
"player.play": "Play/Pause",
"player.next": "Next",
"player.mute": "Mute",
"player.status.connected": "Connected",
"player.status.disconnected": "Disconnected",
"player.no_media": "No media playing",
"player.source": "Source:",
"player.unknown_source": "Unknown",
"state.playing": "Playing",
"state.paused": "Paused",
"state.stopped": "Stopped",
"state.idle": "Idle",
"scripts.quick_actions": "Quick Actions",
"scripts.no_scripts": "No scripts configured",
"scripts.management": "Script Management",
"scripts.add": "Add",
"scripts.table.name": "Name",
"scripts.table.label": "Label",
"scripts.table.command": "Command",
"scripts.table.timeout": "Timeout",
"scripts.table.actions": "Actions",
"scripts.empty": "No scripts configured. Click 'Add' to create one.",
"scripts.dialog.add": "Add Script",
"scripts.dialog.edit": "Edit Script",
"scripts.field.name": "Script Name *",
"scripts.field.label": "Label",
"scripts.field.command": "Command *",
"scripts.field.description": "Description",
"scripts.field.icon": "Icon (MDI)",
"scripts.field.timeout": "Timeout (seconds)",
"scripts.placeholder.name": "Only letters, numbers, and underscores allowed",
"scripts.placeholder.label": "Human-readable name",
"scripts.placeholder.command": "e.g., shutdown /s /t 0",
"scripts.placeholder.description": "What does this script do?",
"scripts.placeholder.icon": "e.g., mdi:power",
"scripts.button.cancel": "Cancel",
"scripts.button.save": "Save",
"scripts.button.edit": "Edit",
"scripts.button.delete": "Delete",
"scripts.msg.executed": "{name} executed successfully",
"scripts.msg.execute_failed": "Failed to execute {name}",
"scripts.msg.execute_error": "Error executing {name}",
"scripts.msg.created": "Script created successfully",
"scripts.msg.updated": "Script updated successfully",
"scripts.msg.create_failed": "Failed to create script",
"scripts.msg.update_failed": "Failed to update script",
"scripts.msg.deleted": "Script deleted successfully",
"scripts.msg.delete_failed": "Failed to delete script",
"scripts.msg.not_found": "Script not found",
"scripts.msg.load_failed": "Failed to load script details",
"scripts.msg.list_failed": "Failed to load scripts",
"scripts.confirm.delete": "Are you sure you want to delete the script \"{name}\"?",
"callbacks.management": "Callback Management",
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
"callbacks.add": "Add",
"callbacks.table.event": "Event",
"callbacks.table.command": "Command",
"callbacks.table.timeout": "Timeout",
"callbacks.table.actions": "Actions",
"callbacks.empty": "No callbacks configured. Click 'Add' to create one.",
"callbacks.dialog.add": "Add Callback",
"callbacks.dialog.edit": "Edit Callback",
"callbacks.field.event": "Event *",
"callbacks.field.command": "Command *",
"callbacks.field.timeout": "Timeout (seconds)",
"callbacks.field.workdir": "Working Directory",
"callbacks.placeholder.event": "Select event...",
"callbacks.placeholder.command": "e.g., shutdown /s /t 0",
"callbacks.placeholder.workdir": "Optional",
"callbacks.button.cancel": "Cancel",
"callbacks.button.save": "Save",
"callbacks.button.edit": "Edit",
"callbacks.button.delete": "Delete",
"callbacks.event.on_play": "on_play - After play succeeds",
"callbacks.event.on_pause": "on_pause - After pause succeeds",
"callbacks.event.on_stop": "on_stop - After stop succeeds",
"callbacks.event.on_next": "on_next - After next track succeeds",
"callbacks.event.on_previous": "on_previous - After previous track succeeds",
"callbacks.event.on_volume": "on_volume - After volume change",
"callbacks.event.on_mute": "on_mute - After mute toggle",
"callbacks.event.on_seek": "on_seek - After seek succeeds",
"callbacks.event.on_turn_on": "on_turn_on - Callback-only action",
"callbacks.event.on_turn_off": "on_turn_off - Callback-only action",
"callbacks.event.on_toggle": "on_toggle - Callback-only action",
"callbacks.msg.created": "Callback created successfully",
"callbacks.msg.updated": "Callback updated successfully",
"callbacks.msg.create_failed": "Failed to create callback",
"callbacks.msg.update_failed": "Failed to update callback",
"callbacks.msg.deleted": "Callback deleted successfully",
"callbacks.msg.delete_failed": "Failed to delete callback",
"callbacks.msg.not_found": "Callback not found",
"callbacks.msg.load_failed": "Failed to load callback details",
"callbacks.msg.list_failed": "Failed to load callbacks",
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?"
}

View File

@@ -0,0 +1,109 @@
{
"app.title": "Медиа Сервер",
"auth.message": "Введите API токен для подключения к медиа серверу.",
"auth.placeholder": "Введите API токен",
"auth.connect": "Подключиться",
"auth.help": "Чтобы получить токен, выполните:",
"auth.logout": "Выйти",
"auth.logout.title": "Очистить сохраненный токен",
"auth.invalid": "Неверный токен. Пожалуйста, попробуйте снова.",
"auth.cleared": "Токен очищен. Пожалуйста, введите новый токен.",
"auth.required": "Пожалуйста, введите токен",
"player.theme": "Переключить тему",
"player.locale": "Изменить язык",
"player.previous": "Предыдущий",
"player.play": "Воспроизвести/Пауза",
"player.next": "Следующий",
"player.mute": "Без звука",
"player.status.connected": "Подключено",
"player.status.disconnected": "Отключено",
"player.no_media": "Медиа не воспроизводится",
"player.source": "Источник:",
"player.unknown_source": "Неизвестно",
"state.playing": "Воспроизведение",
"state.paused": "Пауза",
"state.stopped": "Остановлено",
"state.idle": "Ожидание",
"scripts.quick_actions": "Быстрые Действия",
"scripts.no_scripts": "Скрипты не настроены",
"scripts.management": "Управление Скриптами",
"scripts.add": "Добавить",
"scripts.table.name": "Имя",
"scripts.table.label": "Метка",
"scripts.table.command": "Команда",
"scripts.table.timeout": "Таймаут",
"scripts.table.actions": "Действия",
"scripts.empty": "Скрипты не настроены. Нажмите 'Добавить' для создания.",
"scripts.dialog.add": "Добавить Скрипт",
"scripts.dialog.edit": "Редактировать Скрипт",
"scripts.field.name": "Имя Скрипта *",
"scripts.field.label": "Метка",
"scripts.field.command": "Команда *",
"scripts.field.description": "Описание",
"scripts.field.icon": "Иконка (MDI)",
"scripts.field.timeout": "Таймаут (секунды)",
"scripts.placeholder.name": "Только буквы, цифры и подчеркивания",
"scripts.placeholder.label": "Человеко-читаемое имя",
"scripts.placeholder.command": "например, shutdown /s /t 0",
"scripts.placeholder.description": "Что делает этот скрипт?",
"scripts.placeholder.icon": "например, mdi:power",
"scripts.button.cancel": "Отмена",
"scripts.button.save": "Сохранить",
"scripts.button.edit": "Редактировать",
"scripts.button.delete": "Удалить",
"scripts.msg.executed": "{name} выполнен успешно",
"scripts.msg.execute_failed": "Не удалось выполнить {name}",
"scripts.msg.execute_error": "Ошибка выполнения {name}",
"scripts.msg.created": "Скрипт создан успешно",
"scripts.msg.updated": "Скрипт обновлен успешно",
"scripts.msg.create_failed": "Не удалось создать скрипт",
"scripts.msg.update_failed": "Не удалось обновить скрипт",
"scripts.msg.deleted": "Скрипт удален успешно",
"scripts.msg.delete_failed": "Не удалось удалить скрипт",
"scripts.msg.not_found": "Скрипт не найден",
"scripts.msg.load_failed": "Не удалось загрузить данные скрипта",
"scripts.msg.list_failed": "Не удалось загрузить скрипты",
"scripts.confirm.delete": "Вы уверены, что хотите удалить скрипт \"{name}\"?",
"callbacks.management": "Управление Обратными Вызовами",
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
"callbacks.add": "Добавить",
"callbacks.table.event": "Событие",
"callbacks.table.command": "Команда",
"callbacks.table.timeout": "Таймаут",
"callbacks.table.actions": "Действия",
"callbacks.empty": "Обратные вызовы не настроены. Нажмите 'Добавить' для создания.",
"callbacks.dialog.add": "Добавить Обратный Вызов",
"callbacks.dialog.edit": "Редактировать Обратный Вызов",
"callbacks.field.event": "Событие *",
"callbacks.field.command": "Команда *",
"callbacks.field.timeout": "Таймаут (секунды)",
"callbacks.field.workdir": "Рабочая Директория",
"callbacks.placeholder.event": "Выберите событие...",
"callbacks.placeholder.command": "например, shutdown /s /t 0",
"callbacks.placeholder.workdir": "Опционально",
"callbacks.button.cancel": "Отмена",
"callbacks.button.save": "Сохранить",
"callbacks.button.edit": "Редактировать",
"callbacks.button.delete": "Удалить",
"callbacks.event.on_play": "on_play - После успешного воспроизведения",
"callbacks.event.on_pause": "on_pause - После успешной паузы",
"callbacks.event.on_stop": "on_stop - После успешной остановки",
"callbacks.event.on_next": "on_next - После успешного перехода к следующему",
"callbacks.event.on_previous": "on_previous - После успешного перехода к предыдущему",
"callbacks.event.on_volume": "on_volume - После изменения громкости",
"callbacks.event.on_mute": "on_mute - После переключения звука",
"callbacks.event.on_seek": "on_seek - После успешной перемотки",
"callbacks.event.on_turn_on": "on_turn_on - Действие только для обратных вызовов",
"callbacks.event.on_turn_off": "on_turn_off - Действие только для обратных вызовов",
"callbacks.event.on_toggle": "on_toggle - Действие только для обратных вызовов",
"callbacks.msg.created": "Обратный вызов создан успешно",
"callbacks.msg.updated": "Обратный вызов обновлен успешно",
"callbacks.msg.create_failed": "Не удалось создать обратный вызов",
"callbacks.msg.update_failed": "Не удалось обновить обратный вызов",
"callbacks.msg.deleted": "Обратный вызов удален успешно",
"callbacks.msg.delete_failed": "Не удалось удалить обратный вызов",
"callbacks.msg.not_found": "Обратный вызов не найден",
"callbacks.msg.load_failed": "Не удалось загрузить данные обратного вызова",
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?"
}