Initial commit: Media server and Home Assistant integration

- FastAPI server for Windows media control via WinRT/SMTC
- Home Assistant custom integration with media player entity
- Script button entities for system commands
- Position tracking with grace period for track skip handling
- Server availability detection in HA entity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 13:08:40 +03:00
commit 67a89e8349
37 changed files with 5058 additions and 0 deletions

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv/
# IDE
.idea/
.vscode/
.claude/
*.swp
*.swo
*~
# Config files with secrets
config.yaml
config.json
.env
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db

50
CLAUDE.md Normal file
View File

@@ -0,0 +1,50 @@
# Media Server for Home Assistant
## Project Overview
A server-client system to control PC media playback from Home Assistant.
- **Server**: FastAPI REST API on Windows controlling system-wide media via WinRT
- **Client**: Home Assistant custom integration exposing a media player entity
## Running the Server
### Manual Start
```bash
cd c:\Users\Alexei\Documents\haos-integration-media-player
python -m media_server.main
```
### Auto-Start on Boot (Windows Task Scheduler)
Run in **Administrator PowerShell** from the project root:
```powershell
.\media_server\service\install_task_windows.ps1
```
To remove the scheduled task:
```powershell
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
```
## Home Assistant Integration
Copy `custom_components/remote_media_player/` to your Home Assistant config folder.
Integration files location: `U:\custom_components\remote_media_player`
## API Token
The API token is generated on first run and displayed in the console output.
Configure the same token in Home Assistant integration settings.
## Server Port
Default: `8765`
## Git Rules
Always ask for user approval before committing changes to git.

94
README.md Normal file
View File

@@ -0,0 +1,94 @@
# Remote Media Player for Home Assistant
Control your PC's media playback from Home Assistant.
## Components
| Component | Description | Documentation |
|-----------|-------------|---------------|
| [Media Server](media_server/) | REST API server for your PC | [README](media_server/README.md) |
| [HAOS Integration](custom_components/remote_media_player/) | Home Assistant custom component | [README](custom_components/remote_media_player/README.md) |
## Overview
```
┌─────────────────────┐ HTTP/REST ┌─────────────────────┐
│ Home Assistant │◄─────────────────────────►│ Your PC │
│ │ (Token Auth) │ │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ Media Player │ │ │ │ Media Server │ │
│ │ Entity │ │ │ │ (FastAPI) │ │
│ └───────────────┘ │ │ └───────┬───────┘ │
│ │ │ │ │
└─────────────────────┘ │ ┌───────▼───────┐ │
│ │ Media Control │ │
│ │ - Windows │ │
│ │ - Linux │ │
│ │ - macOS │ │
│ │ - Android │ │
│ └───────────────┘ │
└─────────────────────┘
```
## Features
- Play/Pause/Stop media
- Next/Previous track
- Volume control and mute
- Seek within tracks
- Display current track info (title, artist, album, artwork)
- Secure token-based authentication
## Supported Platforms
| Platform | Media Control | Volume Control | Status |
|----------|---------------|----------------|--------|
| Windows | WinRT Media Transport | pycaw | Fully tested |
| Linux | MPRIS D-Bus | PulseAudio/PipeWire | Not tested |
| macOS | AppleScript | System volume | Not tested |
| Android | Termux:API | Termux volume | Not tested |
> **Note:** Windows is the primary supported platform. Linux, macOS, and Android implementations exist but have not been thoroughly tested and may have limited functionality.
## Quick Start
### 1. Set up the Server (on your PC)
```bash
cd media_server
pip install -r requirements.txt
python -m media_server.main --generate-config
python -m media_server.main
```
See [Media Server README](media_server/README.md) for detailed instructions.
### 2. Set up Home Assistant Integration
1. Copy `custom_components/remote_media_player/` to your HA config
2. Restart Home Assistant
3. Add integration via UI with your server's IP and token
See [Integration README](custom_components/remote_media_player/README.md) for detailed instructions.
## Project Structure
```
haos-integration-media-player/
├── media_server/ # Server component
│ ├── main.py # Entry point
│ ├── routes/ # API endpoints
│ ├── services/ # Platform media controllers
│ └── service/ # Service installers
├── custom_components/
│ └── remote_media_player/ # HAOS Integration
│ ├── media_player.py # Media player entity
│ └── config_flow.py # UI configuration
└── README.md
```
## License
MIT License

View File

@@ -0,0 +1,324 @@
# Remote Media Player - Home Assistant Integration
A Home Assistant custom component that allows you to control a remote PC's media playback as a media player entity.
## Features
- Full media player controls (play, pause, stop, next, previous)
- Volume control and mute
- Seek support
- Displays current track info (title, artist, album, artwork)
- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
- Configurable via Home Assistant UI
- Polling with adjustable interval
## Requirements
- Home Assistant 2023.1 or newer
- A running [Media Server](../../media_server/README.md) on your PC
## Installation
### HACS (Recommended)
1. Open HACS in Home Assistant
2. Click the three dots menu > Custom repositories
3. Add this repository URL and select "Integration"
4. Search for "Remote Media Player" and install
5. Restart Home Assistant
### Manual Installation
1. Copy the `remote_media_player` folder to your Home Assistant `config/custom_components/` directory:
```
config/
└── custom_components/
└── remote_media_player/
├── __init__.py
├── api_client.py
├── button.py
├── config_flow.py
├── const.py
├── manifest.json
├── media_player.py
├── services.yaml
├── strings.json
└── translations/
└── en.json
```
2. Restart Home Assistant
## Configuration
### Via UI (Recommended)
1. Go to **Settings** > **Devices & Services**
2. Click **+ Add Integration**
3. Search for "Remote Media Player"
4. Enter the connection details:
- **Host**: IP address or hostname of your PC running Media Server
- **Port**: Server port (default: 8765)
- **API Token**: The authentication token from your server
- **Name**: Display name for this media player (optional)
- **Poll Interval**: How often to update status (default: 5 seconds)
### Finding Your API Token
On the PC running Media Server:
```bash
python -m media_server.main --show-token
```
Or check the config file:
- Windows: `%APPDATA%\media-server\config.yaml`
- Linux/macOS: `~/.config/media-server/config.yaml`
## Usage
Once configured, the integration creates a media player entity that you can:
### Control via UI
- Use the media player card in Lovelace
- Control from the entity's detail page
### Control via Services
```yaml
# Play
service: media_player.media_play
target:
entity_id: media_player.remote_media_player
# Pause
service: media_player.media_pause
target:
entity_id: media_player.remote_media_player
# Set volume (0.0 - 1.0)
service: media_player.volume_set
target:
entity_id: media_player.remote_media_player
data:
volume_level: 0.5
# Mute
service: media_player.volume_mute
target:
entity_id: media_player.remote_media_player
data:
is_volume_muted: true
# Next/Previous track
service: media_player.media_next_track
target:
entity_id: media_player.remote_media_player
# Seek to position (seconds)
service: media_player.media_seek
target:
entity_id: media_player.remote_media_player
data:
seek_position: 60
```
### Automations
Example: Pause PC media when leaving home
```yaml
automation:
- alias: "Pause PC media when leaving"
trigger:
- platform: state
entity_id: person.your_name
from: "home"
action:
- service: media_player.media_pause
target:
entity_id: media_player.remote_media_player
```
Example: Lower PC volume during quiet hours
```yaml
automation:
- alias: "Lower PC volume at night"
trigger:
- platform: time
at: "22:00:00"
action:
- service: media_player.volume_set
target:
entity_id: media_player.remote_media_player
data:
volume_level: 0.3
```
## Script Buttons
The integration automatically creates **button entities** for each script defined on your Media Server. These buttons allow you to:
- Lock/unlock the workstation
- Shutdown, restart, or put the PC to sleep
- Hibernate the PC
- Execute custom commands
### Available Buttons
After setup, you'll see button entities like:
- `button.remote_media_player_lock_screen`
- `button.remote_media_player_shutdown`
- `button.remote_media_player_restart`
- `button.remote_media_player_sleep`
- `button.remote_media_player_hibernate`
### Adding Scripts
Scripts are configured on the Media Server in `config.yaml`:
```yaml
scripts:
lock_screen:
command: "rundll32.exe user32.dll,LockWorkStation"
label: "Lock Screen"
description: "Lock the workstation"
timeout: 5
shell: true
```
After adding scripts, restart the Media Server and reload the integration in Home Assistant.
### Using Script Buttons
#### Via UI
Add button entities to your dashboard using a button card or entities card.
#### Via Automation
```yaml
automation:
- alias: "Lock PC when leaving home"
trigger:
- platform: state
entity_id: person.your_name
from: "home"
action:
- service: button.press
target:
entity_id: button.remote_media_player_lock_screen
```
### Execute Script Service
You can also execute scripts with arguments using the service:
```yaml
service: remote_media_player.execute_script
data:
script_name: "echo_test"
args:
- "arg1"
- "arg2"
```
## Lovelace Card Examples
### Basic Media Control Card
```yaml
type: media-control
entity: media_player.remote_media_player
```
### Mini Media Player (requires custom card)
```yaml
type: custom:mini-media-player
entity: media_player.remote_media_player
artwork: cover
source: icon
```
### Entities Card
```yaml
type: entities
entities:
- entity: media_player.remote_media_player
type: custom:slider-entity-row
full_row: true
```
## Entity Attributes
The media player entity exposes these attributes:
| Attribute | Description |
|-----------|-------------|
| `media_title` | Current track title |
| `media_artist` | Current artist |
| `media_album_name` | Current album |
| `media_duration` | Track duration in seconds |
| `media_position` | Current position in seconds |
| `volume_level` | Volume (0.0 - 1.0) |
| `is_volume_muted` | Mute state |
| `source` | Media source/player name |
## Options
After initial setup, you can adjust options:
1. Go to **Settings** > **Devices & Services**
2. Find "Remote Media Player" and click **Configure**
3. Adjust the poll interval as needed
Lower poll intervals = more responsive but more network traffic.
## Troubleshooting
### Integration not found
- Restart Home Assistant after installing
- Check that all files are in the correct location
- Check Home Assistant logs for errors
### Cannot connect to server
- Verify the server is running: `curl http://YOUR_PC_IP:8765/api/health`
- Check firewall settings on the PC
- Ensure the IP address is correct
### Invalid token error
- Double-check the token matches exactly
- Regenerate token if needed: `python -m media_server.main --generate-config`
### Entity shows unavailable
- Check server is running
- Check network connectivity
- Review Home Assistant logs for connection errors
### Media controls don't work
- Ensure media is playing on the PC
- Check server logs for errors
- Verify the media player supports the requested action
## Multiple PCs
You can add multiple Media Server instances:
1. Run Media Server on each PC (use different tokens)
2. Add the integration multiple times in Home Assistant
3. Give each a unique name
## Supported Features
| Feature | Supported |
|---------|-----------|
| Play | Yes |
| Pause | Yes |
| Stop | Yes |
| Next Track | Yes |
| Previous Track | Yes |
| Volume Set | Yes |
| Volume Mute | Yes |
| Seek | Yes |
| Script Buttons | Yes |
| Browse Media | No |
| Play Media | No |
| Shuffle/Repeat | No |
## License
MIT License

View File

@@ -0,0 +1,158 @@
"""The Remote Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from .api_client import MediaServerClient, MediaServerError
from .const import (
ATTR_SCRIPT_ARGS,
ATTR_SCRIPT_NAME,
CONF_HOST,
CONF_PORT,
CONF_TOKEN,
DOMAIN,
SERVICE_EXECUTE_SCRIPT,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON]
# Service schema for execute_script
SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_SCRIPT_NAME): cv.string,
vol.Optional(ATTR_SCRIPT_ARGS, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
}
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Remote Media Player from a config entry.
Args:
hass: Home Assistant instance
entry: Config entry
Returns:
True if setup was successful
"""
_LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id)
# Create API client
client = MediaServerClient(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN],
)
# Verify connection
if not await client.check_connection():
_LOGGER.error("Failed to connect to Media Server")
await client.close()
return False
# Store client in hass.data
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
"client": client,
}
# Register services if not already registered
if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT):
async def async_execute_script(call: ServiceCall) -> dict[str, Any]:
"""Execute a script on the media server."""
script_name = call.data[ATTR_SCRIPT_NAME]
script_args = call.data.get(ATTR_SCRIPT_ARGS, [])
_LOGGER.debug(
"Executing script '%s' with args: %s", script_name, script_args
)
# Get all clients and execute on all of them
results = {}
for entry_id, data in hass.data[DOMAIN].items():
client: MediaServerClient = data["client"]
try:
result = await client.execute_script(script_name, script_args)
results[entry_id] = result
_LOGGER.info(
"Script '%s' executed on %s: success=%s",
script_name,
entry_id,
result.get("success", False),
)
except MediaServerError as err:
_LOGGER.error(
"Failed to execute script '%s' on %s: %s",
script_name,
entry_id,
err,
)
results[entry_id] = {"success": False, "error": str(err)}
return results
hass.services.async_register(
DOMAIN,
SERVICE_EXECUTE_SCRIPT,
async_execute_script,
schema=SERVICE_EXECUTE_SCRIPT_SCHEMA,
)
# Forward setup to platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register update listener for options
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.
Args:
hass: Home Assistant instance
entry: Config entry
Returns:
True if unload was successful
"""
_LOGGER.debug("Unloading Remote Media Player: %s", entry.entry_id)
# Unload platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
# Close client and remove data
data = hass.data[DOMAIN].pop(entry.entry_id)
await data["client"].close()
# Remove services if this was the last entry
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT)
return unload_ok
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update.
Args:
hass: Home Assistant instance
entry: Config entry
"""
_LOGGER.debug("Options updated for: %s", entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,267 @@
"""API client for communicating with the Media Server."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from aiohttp import ClientError, ClientResponseError
from .const import (
API_HEALTH,
API_STATUS,
API_PLAY,
API_PAUSE,
API_STOP,
API_NEXT,
API_PREVIOUS,
API_VOLUME,
API_MUTE,
API_SEEK,
API_SCRIPTS_LIST,
API_SCRIPTS_EXECUTE,
)
_LOGGER = logging.getLogger(__name__)
class MediaServerError(Exception):
"""Base exception for Media Server errors."""
class MediaServerConnectionError(MediaServerError):
"""Exception for connection errors."""
class MediaServerAuthError(MediaServerError):
"""Exception for authentication errors."""
class MediaServerClient:
"""Client for the Media Server REST API."""
def __init__(
self,
host: str,
port: int,
token: str,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Initialize the client.
Args:
host: Server hostname or IP address
port: Server port
token: API authentication token
session: Optional aiohttp session (will create one if not provided)
"""
self._host = host
self._port = int(port) # Ensure port is an integer
self._token = token
self._session = session
self._own_session = session is None
self._base_url = f"http://{host}:{self._port}"
async def _ensure_session(self) -> aiohttp.ClientSession:
"""Ensure we have an aiohttp session."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
self._own_session = True
return self._session
async def close(self) -> None:
"""Close the client session."""
if self._own_session and self._session and not self._session.closed:
await self._session.close()
def _get_headers(self) -> dict[str, str]:
"""Get headers for API requests."""
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
endpoint: str,
json_data: dict | None = None,
auth_required: bool = True,
) -> dict[str, Any]:
"""Make an API request.
Args:
method: HTTP method (GET, POST, etc.)
endpoint: API endpoint path
json_data: Optional JSON body data
auth_required: Whether to include authentication header
Returns:
Response data as dictionary
Raises:
MediaServerConnectionError: On connection errors
MediaServerAuthError: On authentication errors
MediaServerError: On other errors
"""
session = await self._ensure_session()
url = f"{self._base_url}{endpoint}"
headers = self._get_headers() if auth_required else {}
try:
timeout = aiohttp.ClientTimeout(total=10)
async with session.request(
method, url, headers=headers, json=json_data, timeout=timeout
) as response:
if response.status == 401:
raise MediaServerAuthError("Invalid API token")
if response.status == 403:
raise MediaServerAuthError("Access forbidden")
response.raise_for_status()
return await response.json()
except aiohttp.ClientConnectorError as err:
raise MediaServerConnectionError(
f"Cannot connect to server at {self._base_url}: {err}"
) from err
except ClientResponseError as err:
raise MediaServerError(f"API error: {err.status} {err.message}") from err
except ClientError as err:
raise MediaServerConnectionError(f"Connection error: {err}") from err
async def check_connection(self) -> bool:
"""Check if the server is reachable and token is valid.
Returns:
True if connection is successful
"""
try:
# First check health (no auth)
await self._request("GET", API_HEALTH, auth_required=False)
# Then check auth by getting status
await self._request("GET", API_STATUS)
return True
except MediaServerError:
return False
async def get_health(self) -> dict[str, Any]:
"""Get server health status (no authentication required).
Returns:
Health status data
"""
return await self._request("GET", API_HEALTH, auth_required=False)
async def get_status(self) -> dict[str, Any]:
"""Get current media playback status.
Returns:
Media status data including state, title, artist, volume, etc.
"""
data = await self._request("GET", API_STATUS)
# Convert relative album_art_url to absolute URL with token and cache-buster
if data.get("album_art_url") and data["album_art_url"].startswith("/"):
# Add track info hash to force HA to re-fetch when track changes
import hashlib
track_id = f"{data.get('title', '')}-{data.get('artist', '')}"
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
data["album_art_url"] = f"{self._base_url}{data['album_art_url']}?token={self._token}&t={track_hash}"
return data
async def play(self) -> dict[str, Any]:
"""Resume or start playback.
Returns:
Response data
"""
return await self._request("POST", API_PLAY)
async def pause(self) -> dict[str, Any]:
"""Pause playback.
Returns:
Response data
"""
return await self._request("POST", API_PAUSE)
async def stop(self) -> dict[str, Any]:
"""Stop playback.
Returns:
Response data
"""
return await self._request("POST", API_STOP)
async def next_track(self) -> dict[str, Any]:
"""Skip to next track.
Returns:
Response data
"""
return await self._request("POST", API_NEXT)
async def previous_track(self) -> dict[str, Any]:
"""Go to previous track.
Returns:
Response data
"""
return await self._request("POST", API_PREVIOUS)
async def set_volume(self, volume: int) -> dict[str, Any]:
"""Set the volume level.
Args:
volume: Volume level (0-100)
Returns:
Response data
"""
return await self._request("POST", API_VOLUME, {"volume": volume})
async def toggle_mute(self) -> dict[str, Any]:
"""Toggle mute state.
Returns:
Response data with new mute state
"""
return await self._request("POST", API_MUTE)
async def seek(self, position: float) -> dict[str, Any]:
"""Seek to a position in the current track.
Args:
position: Position in seconds
Returns:
Response data
"""
return await self._request("POST", API_SEEK, {"position": position})
async def list_scripts(self) -> list[dict[str, Any]]:
"""List available scripts on the server.
Returns:
List of scripts with name, description, and timeout
"""
return await self._request("GET", API_SCRIPTS_LIST)
async def execute_script(
self, script_name: str, args: list[str] | None = None
) -> dict[str, Any]:
"""Execute a script on the server.
Args:
script_name: Name of the script to execute
args: Optional list of arguments to pass to the script
Returns:
Execution result with success, exit_code, stdout, stderr
"""
endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}"
json_data = {"args": args or []}
return await self._request("POST", endpoint, json_data)

View File

@@ -0,0 +1,131 @@
"""Button platform for Remote Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .api_client import MediaServerClient, MediaServerError
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up script buttons from a config entry."""
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
try:
scripts = await client.list_scripts()
except MediaServerError as err:
_LOGGER.error("Failed to fetch scripts list: %s", err)
return
entities = [
ScriptButtonEntity(
client=client,
entry=entry,
script_name=script["name"],
script_label=script["label"],
script_description=script.get("description", ""),
)
for script in scripts
]
if entities:
async_add_entities(entities)
_LOGGER.info("Added %d script button entities", len(entities))
class ScriptButtonEntity(ButtonEntity):
"""Button entity for executing a script on the media server."""
_attr_has_entity_name = True
def __init__(
self,
client: MediaServerClient,
entry: ConfigEntry,
script_name: str,
script_label: str,
script_description: str,
) -> None:
"""Initialize the script button."""
self._client = client
self._entry = entry
self._script_name = script_name
self._script_label = script_label
self._script_description = script_description
# Entity attributes
self._attr_unique_id = f"{entry.entry_id}_script_{script_name}"
self._attr_name = script_label
self._attr_icon = self._get_icon_for_script(script_name)
def _get_icon_for_script(self, script_name: str) -> str:
"""Get an appropriate icon based on script name."""
icon_map = {
"lock": "mdi:lock",
"unlock": "mdi:lock-open",
"shutdown": "mdi:power",
"restart": "mdi:restart",
"sleep": "mdi:sleep",
"hibernate": "mdi:power-sleep",
"cancel": "mdi:cancel",
}
script_lower = script_name.lower()
for keyword, icon in icon_map.items():
if keyword in script_lower:
return icon
return "mdi:script-text"
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="Remote Media Player",
model="Media Server",
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
return {
"script_name": self._script_name,
"description": self._script_description,
}
async def async_press(self) -> None:
"""Handle button press - execute the script."""
_LOGGER.info("Executing script: %s", self._script_name)
try:
result = await self._client.execute_script(self._script_name)
if result.get("success"):
_LOGGER.info(
"Script '%s' executed successfully (exit_code=%s)",
self._script_name,
result.get("exit_code"),
)
else:
_LOGGER.warning(
"Script '%s' failed: %s",
self._script_name,
result.get("stderr") or result.get("error"),
)
except MediaServerError as err:
_LOGGER.error("Failed to execute script '%s': %s", self._script_name, err)
raise

View File

@@ -0,0 +1,224 @@
"""Config flow for Remote Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from .api_client import (
MediaServerClient,
MediaServerConnectionError,
MediaServerAuthError,
)
from .const import (
DOMAIN,
CONF_TOKEN,
CONF_POLL_INTERVAL,
DEFAULT_PORT,
DEFAULT_POLL_INTERVAL,
DEFAULT_NAME,
)
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.
Args:
hass: Home Assistant instance
data: User input data
Returns:
Validated data with title
Raises:
CannotConnect: If connection fails
InvalidAuth: If authentication fails
"""
client = MediaServerClient(
host=data[CONF_HOST],
port=data[CONF_PORT],
token=data[CONF_TOKEN],
)
try:
health = await client.get_health()
# Try authenticated endpoint
await client.get_status()
except MediaServerConnectionError as err:
await client.close()
raise CannotConnect(str(err)) from err
except MediaServerAuthError as err:
await client.close()
raise InvalidAuth(str(err)) from err
finally:
await client.close()
# Return info to store in the config entry
return {
"title": data.get(CONF_NAME, DEFAULT_NAME),
"platform": health.get("platform", "Unknown"),
}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remote Media Player."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step.
Args:
user_input: User provided configuration
Returns:
Flow result
"""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Check if already configured
await self.async_set_unique_id(
f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info["title"],
data=user_input,
)
# Show configuration form
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Required(CONF_PORT, default=DEFAULT_PORT): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=65535,
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Required(CONF_TOKEN): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.PASSWORD
)
),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
),
vol.Optional(
CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="seconds",
mode=selector.NumberSelectorMode.SLIDER,
)
),
}
),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Create the options flow.
Args:
config_entry: Config entry
Returns:
Options flow handler
"""
return OptionsFlowHandler(config_entry)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for Remote Media Player."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow.
Args:
config_entry: Config entry
"""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the options.
Args:
user_input: User provided options
Returns:
Flow result
"""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_POLL_INTERVAL,
default=self._config_entry.options.get(
CONF_POLL_INTERVAL,
self._config_entry.data.get(
CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL
),
),
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=60,
step=1,
unit_of_measurement="seconds",
mode=selector.NumberSelectorMode.SLIDER,
)
),
}
),
)
class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
class InvalidAuth(Exception):
"""Error to indicate there is invalid auth."""

View File

@@ -0,0 +1,36 @@
"""Constants for the Remote Media Player integration."""
DOMAIN = "remote_media_player"
# Configuration keys
CONF_HOST = "host"
CONF_PORT = "port"
CONF_TOKEN = "token"
CONF_POLL_INTERVAL = "poll_interval"
CONF_NAME = "name"
# Default values
DEFAULT_PORT = 8765
DEFAULT_POLL_INTERVAL = 5
DEFAULT_NAME = "Remote Media Player"
# API endpoints
API_HEALTH = "/api/health"
API_STATUS = "/api/media/status"
API_PLAY = "/api/media/play"
API_PAUSE = "/api/media/pause"
API_STOP = "/api/media/stop"
API_NEXT = "/api/media/next"
API_PREVIOUS = "/api/media/previous"
API_VOLUME = "/api/media/volume"
API_MUTE = "/api/media/mute"
API_SEEK = "/api/media/seek"
API_SCRIPTS_LIST = "/api/scripts/list"
API_SCRIPTS_EXECUTE = "/api/scripts/execute"
# Service names
SERVICE_EXECUTE_SCRIPT = "execute_script"
# Service attributes
ATTR_SCRIPT_NAME = "script_name"
ATTR_SCRIPT_ARGS = "args"

View File

@@ -0,0 +1,12 @@
{
"domain": "remote_media_player",
"name": "Remote Media Player",
"codeowners": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-username/haos-integration-media-player",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["aiohttp>=3.8.0"],
"version": "1.0.0"
}

View File

@@ -0,0 +1,345 @@
"""Media player platform for Remote Media Player integration."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .api_client import MediaServerClient, MediaServerError
from .const import (
DOMAIN,
CONF_HOST,
CONF_PORT,
CONF_POLL_INTERVAL,
DEFAULT_POLL_INTERVAL,
DEFAULT_NAME,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the media player platform.
Args:
hass: Home Assistant instance
entry: Config entry
async_add_entities: Callback to add entities
"""
_LOGGER.debug("Setting up media player platform for %s", entry.entry_id)
try:
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
except KeyError:
_LOGGER.error("Client not found in hass.data for entry %s", entry.entry_id)
return
# Get poll interval from options or data
poll_interval = entry.options.get(
CONF_POLL_INTERVAL,
entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL),
)
# Create update coordinator
coordinator = MediaPlayerCoordinator(
hass,
client,
poll_interval,
)
# Fetch initial data - don't fail setup if this fails
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
_LOGGER.warning("Initial data fetch failed, will retry: %s", err)
# Continue anyway - the coordinator will retry
# Create and add entity
entity = RemoteMediaPlayerEntity(
coordinator,
entry,
)
_LOGGER.info("Adding media player entity: %s", entity.unique_id)
async_add_entities([entity])
class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for fetching media player data."""
def __init__(
self,
hass: HomeAssistant,
client: MediaServerClient,
poll_interval: int,
) -> None:
"""Initialize the coordinator.
Args:
hass: Home Assistant instance
client: Media Server API client
poll_interval: Update interval in seconds
"""
super().__init__(
hass,
_LOGGER,
name="Remote Media Player",
update_interval=timedelta(seconds=poll_interval),
)
self.client = client
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the API.
Returns:
Media status data
Raises:
UpdateFailed: On API errors
"""
try:
data = await self.client.get_status()
_LOGGER.debug("Received media status: %s", data)
return data
except MediaServerError as err:
raise UpdateFailed(f"Error communicating with server: {err}") from err
except Exception as err:
_LOGGER.exception("Unexpected error fetching media status")
raise UpdateFailed(f"Unexpected error: {err}") from err
class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPlayerEntity):
"""Representation of a Remote Media Player."""
_attr_has_entity_name = True
_attr_name = None
@property
def available(self) -> bool:
"""Return True if entity is available."""
# Use the coordinator's last_update_success to detect server availability
return self.coordinator.last_update_success
def __init__(
self,
coordinator: MediaPlayerCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the media player entity.
Args:
coordinator: Data update coordinator
entry: Config entry
"""
super().__init__(coordinator)
self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_media_player"
# Device info - must match button.py identifiers
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="Remote Media Player",
model="Media Server",
sw_version="1.0.0",
configuration_url=f"http://{entry.data[CONF_HOST]}:{int(entry.data[CONF_PORT])}/docs",
)
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Return the supported features."""
return (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
)
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the player."""
if self.coordinator.data is None:
return MediaPlayerState.OFF
state = self.coordinator.data.get("state", "idle")
state_map = {
"playing": MediaPlayerState.PLAYING,
"paused": MediaPlayerState.PAUSED,
"stopped": MediaPlayerState.IDLE,
"idle": MediaPlayerState.IDLE,
}
return state_map.get(state, MediaPlayerState.IDLE)
@property
def volume_level(self) -> float | None:
"""Return the volume level (0..1)."""
if self.coordinator.data is None:
return None
volume = self.coordinator.data.get("volume", 0)
return volume / 100.0
@property
def is_volume_muted(self) -> bool | None:
"""Return True if volume is muted."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get("muted", False)
@property
def media_content_type(self) -> MediaType | None:
"""Return the content type of current playing media."""
return MediaType.MUSIC
@property
def media_title(self) -> str | None:
"""Return the title of current playing media."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get("title")
@property
def media_artist(self) -> str | None:
"""Return the artist of current playing media."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get("artist")
@property
def media_album_name(self) -> str | None:
"""Return the album name of current playing media."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get("album")
@property
def media_image_url(self) -> str | None:
"""Return the image URL of current playing media."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get("album_art_url")
@property
def media_duration(self) -> int | None:
"""Return the duration of current playing media in seconds."""
if self.coordinator.data is None:
return None
duration = self.coordinator.data.get("duration")
return int(duration) if duration is not None else None
@property
def media_position(self) -> int | None:
"""Return the position of current playing media in seconds."""
if self.coordinator.data is None:
return None
position = self.coordinator.data.get("position")
return int(position) if position is not None else None
@property
def media_position_updated_at(self) -> datetime | None:
"""Return when the position was last updated."""
if self.coordinator.data is None:
return None
if self.coordinator.data.get("position") is not None:
# Use last_update_success_time if available, otherwise use current time
if hasattr(self.coordinator, 'last_update_success_time'):
return self.coordinator.last_update_success_time
return datetime.now()
return None
@property
def source(self) -> str | None:
"""Return the current media source."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get("source")
async def async_media_play(self) -> None:
"""Send play command."""
try:
await self.coordinator.client.play()
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to play: %s", err)
async def async_media_pause(self) -> None:
"""Send pause command."""
try:
await self.coordinator.client.pause()
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to pause: %s", err)
async def async_media_stop(self) -> None:
"""Send stop command."""
try:
await self.coordinator.client.stop()
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to stop: %s", err)
async def async_media_next_track(self) -> None:
"""Send next track command."""
try:
await self.coordinator.client.next_track()
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to skip to next track: %s", err)
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
try:
await self.coordinator.client.previous_track()
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to go to previous track: %s", err)
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
try:
await self.coordinator.client.set_volume(int(volume * 100))
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to set volume: %s", err)
async def async_mute_volume(self, mute: bool) -> None:
"""Mute/unmute the volume."""
try:
# Toggle mute (API toggles, so call it if state differs)
if self.is_volume_muted != mute:
await self.coordinator.client.toggle_mute()
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to toggle mute: %s", err)
async def async_media_seek(self, position: float) -> None:
"""Seek to a position."""
try:
await self.coordinator.client.seek(position)
await self.coordinator.async_request_refresh()
except MediaServerError as err:
_LOGGER.error("Failed to seek: %s", err)

View File

@@ -0,0 +1,18 @@
execute_script:
name: Execute Script
description: Execute a pre-defined script on the media server
fields:
script_name:
name: Script Name
description: Name of the script to execute (as defined in server config)
required: true
example: "launch_spotify"
selector:
text:
args:
name: Arguments
description: Optional list of arguments to pass to the script
required: false
example: '["arg1", "arg2"]'
selector:
object:

View File

@@ -0,0 +1,61 @@
{
"config": {
"step": {
"user": {
"title": "Connect to Media Server",
"description": "Enter the connection details for your Media Server.",
"data": {
"host": "Host",
"port": "Port",
"token": "API Token",
"name": "Name",
"poll_interval": "Poll Interval"
},
"data_description": {
"host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration",
"name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the Media Server. Please check the host and port.",
"invalid_auth": "Invalid API token. Please check your token.",
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "This Media Server is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Options",
"data": {
"poll_interval": "Poll Interval"
},
"data_description": {
"poll_interval": "How often to poll for status updates (seconds)"
}
}
}
},
"services": {
"execute_script": {
"name": "Execute Script",
"description": "Execute a pre-defined script on the media server.",
"fields": {
"script_name": {
"name": "Script Name",
"description": "Name of the script to execute (as defined in server config)"
},
"args": {
"name": "Arguments",
"description": "Optional list of arguments to pass to the script"
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
{
"config": {
"step": {
"user": {
"title": "Connect to Media Server",
"description": "Enter the connection details for your Media Server.",
"data": {
"host": "Host",
"port": "Port",
"token": "API Token",
"name": "Name",
"poll_interval": "Poll Interval"
},
"data_description": {
"host": "Hostname or IP address of the Media Server",
"port": "Port number (default: 8765)",
"token": "API authentication token from the server configuration",
"name": "Display name for this media player",
"poll_interval": "How often to poll for status updates (seconds)"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the Media Server. Please check the host and port.",
"invalid_auth": "Invalid API token. Please check your token.",
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "This Media Server is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Options",
"data": {
"poll_interval": "Poll Interval"
},
"data_description": {
"poll_interval": "How often to poll for status updates (seconds)"
}
}
}
},
"services": {
"execute_script": {
"name": "Execute Script",
"description": "Execute a pre-defined script on the media server.",
"fields": {
"script_name": {
"name": "Script Name",
"description": "Name of the script to execute (as defined in server config)"
},
"args": {
"name": "Arguments",
"description": "Optional list of arguments to pass to the script"
}
}
}
}
}

View File

@@ -0,0 +1,61 @@
{
"config": {
"step": {
"user": {
"title": "Подключение к Media Server",
"description": "Введите данные для подключения к Media Server.",
"data": {
"host": "Хост",
"port": "Порт",
"token": "API токен",
"name": "Название",
"poll_interval": "Интервал опроса"
},
"data_description": {
"host": "Имя хоста или IP-адрес Media Server",
"port": "Номер порта (по умолчанию: 8765)",
"token": "Токен аутентификации из конфигурации сервера",
"name": "Отображаемое имя медиаплеера",
"poll_interval": "Частота опроса статуса (в секундах)"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к Media Server. Проверьте хост и порт.",
"invalid_auth": "Неверный API токен. Проверьте токен.",
"unknown": "Произошла непредвиденная ошибка."
},
"abort": {
"already_configured": "Этот Media Server уже настроен."
}
},
"options": {
"step": {
"init": {
"title": "Настройки",
"data": {
"poll_interval": "Интервал опроса"
},
"data_description": {
"poll_interval": "Частота опроса статуса (в секундах)"
}
}
}
},
"services": {
"execute_script": {
"name": "Выполнить скрипт",
"description": "Выполнить предопределённый скрипт на медиасервере.",
"fields": {
"script_name": {
"name": "Имя скрипта",
"description": "Имя скрипта для выполнения (из конфигурации сервера)"
},
"args": {
"name": "Аргументы",
"description": "Необязательный список аргументов для передачи скрипту"
}
}
}
}
}

382
media_server/README.md Normal file
View File

@@ -0,0 +1,382 @@
# Media Server
A REST API server for controlling system media playback on Windows, Linux, macOS, and Android.
## Features
- 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)
- Token-based authentication
- Cross-platform support
## Requirements
- Python 3.10+
- Platform-specific dependencies (see below)
## Installation
### Windows
```bash
pip install -r requirements.txt
```
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
### Linux
```bash
# Install system dependencies
sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
pip install -r requirements.txt
```
### macOS
```bash
pip install -r requirements.txt
```
No additional dependencies - uses built-in `osascript`.
### Android (Termux)
```bash
# In Termux
pkg install python termux-api
pip install -r requirements.txt
```
Requires Termux and Termux:API apps from F-Droid.
## Quick Start
1. Generate configuration with API token:
```bash
python -m media_server.main --generate-config
```
2. View your API token:
```bash
python -m media_server.main --show-token
```
3. Start the server:
```bash
python -m media_server.main
```
4. Test the connection:
```bash
curl http://localhost:8765/api/health
```
5. Test with authentication:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status
```
## Configuration
Configuration file locations:
- Windows: `%APPDATA%\media-server\config.yaml`
- Linux/macOS: `~/.config/media-server/config.yaml`
### config.yaml
```yaml
host: 0.0.0.0
port: 8765
api_token: your-secret-token-here
poll_interval: 1.0
log_level: INFO
```
### Environment Variables
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
```
## API Reference
### Health Check
```
GET /api/health
```
No authentication required. Returns server status and platform info.
**Response:**
```json
{
"status": "healthy",
"platform": "Windows",
"version": "1.0.0"
}
```
### Get Media Status
```
GET /api/media/status
Authorization: Bearer <token>
```
**Response:**
```json
{
"state": "playing",
"title": "Song Title",
"artist": "Artist Name",
"album": "Album Name",
"album_art_url": "https://...",
"duration": 240.5,
"position": 120.3,
"volume": 75,
"muted": false,
"source": "Spotify"
}
```
### Media Controls
All control endpoints require authentication and return `{"success": true}` on success.
| Endpoint | Method | Body | Description |
|----------|--------|------|-------------|
| `/api/media/play` | POST | - | Resume playback |
| `/api/media/pause` | POST | - | Pause playback |
| `/api/media/stop` | POST | - | Stop playback |
| `/api/media/next` | POST | - | Next track |
| `/api/media/previous` | POST | - | Previous track |
| `/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) |
### Script Execution
The server supports executing pre-defined scripts via API.
#### List Scripts
```
GET /api/scripts/list
Authorization: Bearer <token>
```
**Response:**
```json
[
{
"name": "lock_screen",
"label": "Lock Screen",
"description": "Lock the workstation",
"timeout": 5
}
]
```
#### Execute Script
```
POST /api/scripts/execute/{script_name}
Authorization: Bearer <token>
Content-Type: application/json
{"args": []}
```
**Response:**
```json
{
"success": true,
"script": "lock_screen",
"exit_code": 0,
"stdout": "",
"stderr": ""
}
```
### Configuring Scripts
Add scripts in your `config.yaml`:
```yaml
scripts:
lock_screen:
command: "rundll32.exe user32.dll,LockWorkStation"
label: "Lock Screen"
description: "Lock the workstation"
timeout: 5
shell: true
shutdown:
command: "shutdown /s /t 0"
label: "Shutdown"
description: "Shutdown the PC immediately"
timeout: 10
shell: true
restart:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC"
timeout: 10
shell: true
hibernate:
command: "shutdown /h"
label: "Hibernate"
description: "Hibernate the PC"
timeout: 10
shell: true
sleep:
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep"
description: "Put PC to sleep"
timeout: 10
shell: true
```
Script configuration options:
| Field | Required | Description |
|-------|----------|-------------|
| `command` | Yes | Command to execute |
| `label` | No | User-friendly display name (defaults to script name) |
| `description` | No | Description of what the script does |
| `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)
Run in **Administrator PowerShell** from the project root:
```powershell
.\media_server\service\install_task_windows.ps1
```
To remove the scheduled task:
```powershell
Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
```
### Windows Service
Install:
```bash
python -m media_server.service.install_windows install
```
Start/Stop:
```bash
python -m media_server.service.install_windows start
python -m media_server.service.install_windows stop
```
Remove:
```bash
python -m media_server.service.install_windows remove
```
### Linux (systemd)
Install:
```bash
sudo ./service/install_linux.sh install
```
Enable and start for your user:
```bash
sudo systemctl enable media-server@$USER
sudo systemctl start media-server@$USER
```
View logs:
```bash
journalctl -u media-server@$USER -f
```
## Command Line Options
```
python -m media_server.main [OPTIONS]
Options:
--host TEXT Host to bind to (default: 0.0.0.0)
--port INTEGER Port to bind to (default: 8765)
--generate-config Generate default config file and exit
--show-token Show current API token and exit
```
## Security Recommendations
1. **Use HTTPS in production** - Set up a reverse proxy (nginx, Caddy) with SSL
2. **Strong tokens** - Default tokens are 32 random characters; don't use weak tokens
3. **Firewall** - Only expose the port to trusted networks
4. **Secrets management** - Don't commit tokens to version control
## Supported Media Players
### Windows
- Spotify
- Windows Media Player
- VLC
- Groove Music
- Web browsers (Chrome, Edge, Firefox)
- Any app using Windows Media Transport Controls
### Linux
- Any MPRIS-compliant player:
- Spotify
- VLC
- Rhythmbox
- Clementine
- Web browsers
- MPD (with MPRIS bridge)
### macOS
- Spotify
- Apple Music
- VLC (partial)
- QuickTime Player
### Android (via Termux)
- System media controls
- Limited seek support
## Troubleshooting
### "No active media session"
- Ensure a media player is running and has played content
- On Windows, check that the app supports media transport controls
- On Linux, verify MPRIS with: `dbus-send --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames | grep mpris`
### Permission errors on Linux
- Ensure your user has access to the D-Bus session bus
- For systemd service, the `DBUS_SESSION_BUS_ADDRESS` must be set correctly
### Volume control not working
- Windows: Run as administrator if needed
- Linux: Ensure PulseAudio/PipeWire is running
## License
MIT License

3
media_server/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Media Server - REST API for controlling system media playback."""
__version__ = "1.0.0"

111
media_server/auth.py Normal file
View File

@@ -0,0 +1,111 @@
"""Authentication middleware and utilities."""
from typing import Optional
from fastapi import Depends, HTTPException, Query, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .config import settings
security = HTTPBearer(auto_error=False)
async def verify_token(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
"""Verify the API token from the Authorization header.
Args:
request: The incoming request
credentials: The bearer token credentials
Returns:
The validated token
Raises:
HTTPException: If the token is missing or invalid
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials.credentials != settings.api_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return credentials.credentials
class TokenAuth:
"""Dependency class for token authentication."""
def __init__(self, auto_error: bool = True):
self.auto_error = auto_error
async def __call__(
self,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str | None:
"""Verify the token and return it or raise an exception."""
if credentials is None:
if self.auto_error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return None
if credentials.credentials != settings.api_token:
if self.auto_error:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
return None
return credentials.credentials
async def verify_token_or_query(
credentials: HTTPAuthorizationCredentials = Depends(security),
token: Optional[str] = Query(None, description="API token as query parameter"),
) -> str:
"""Verify the API token from header or query parameter.
Useful for endpoints that need to be accessed via URL (like images).
Args:
credentials: The bearer token credentials from header
token: Token from query parameter
Returns:
The validated token
Raises:
HTTPException: If the token is missing or invalid
"""
# Try header first
if credentials is not None:
if credentials.credentials == settings.api_token:
return credentials.credentials
# Try query parameter
if token is not None:
if token == settings.api_token:
return token
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)

141
media_server/config.py Normal file
View File

@@ -0,0 +1,141 @@
"""Configuration management for the media server."""
import os
import secrets
from pathlib import Path
from typing import Optional
import yaml
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class ScriptConfig(BaseModel):
"""Configuration for a custom script."""
command: str = Field(..., description="Command or script to execute")
label: Optional[str] = Field(default=None, description="User-friendly display label")
description: str = Field(default="", description="Script description")
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 Settings(BaseSettings):
"""Application settings loaded from environment or config file."""
model_config = SettingsConfigDict(
env_prefix="MEDIA_SERVER_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Server settings
host: str = Field(default="0.0.0.0", description="Server bind address")
port: int = Field(default=8765, description="Server port")
# Authentication
api_token: str = Field(
default_factory=lambda: secrets.token_urlsafe(32),
description="API authentication token",
)
# Media controller settings
poll_interval: float = Field(
default=1.0, description="Media status poll interval in seconds"
)
# Logging
log_level: str = Field(default="INFO", description="Logging level")
# Custom scripts (loaded separately from YAML)
scripts: dict[str, ScriptConfig] = Field(
default_factory=dict,
description="Custom scripts that can be executed via API",
)
@classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file."""
if path is None:
# Look for config in standard locations
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():
path = search_path
break
if path and path.exists():
with open(path, "r", encoding="utf-8") as f:
config_data = yaml.safe_load(f) or {}
return cls(**config_data)
return cls()
def get_config_dir() -> Path:
"""Get the configuration directory path."""
if os.name == "nt": # Windows
config_dir = Path(os.environ.get("APPDATA", "")) / "media-server"
else: # Linux/Unix
config_dir = Path.home() / ".config" / "media-server"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
def generate_default_config(path: Optional[Path] = None) -> Path:
"""Generate a default configuration file with a new API token."""
if path is None:
path = get_config_dir() / "config.yaml"
config = {
"host": "0.0.0.0",
"port": 8765,
"api_token": secrets.token_urlsafe(32),
"poll_interval": 1.0,
"log_level": "INFO",
"scripts": {
"example_script": {
"command": "echo Hello from Media Server!",
"description": "Example script - echoes a message",
"timeout": 10,
"shell": True,
},
# Add your custom scripts here:
# "shutdown": {
# "command": "shutdown /s /t 60",
# "description": "Shutdown computer in 60 seconds",
# "timeout": 5,
# },
# "lock_screen": {
# "command": "rundll32.exe user32.dll,LockWorkStation",
# "description": "Lock the workstation",
# "timeout": 5,
# },
},
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
return path
# Global settings instance
settings = Settings.load_from_yaml()

112
media_server/main.py Normal file
View File

@@ -0,0 +1,112 @@
"""Media Server - FastAPI application entry point."""
import argparse
import logging
import sys
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings, generate_default_config, get_config_dir
from .routes import health_router, media_router, scripts_router
def setup_logging():
"""Configure application logging."""
logging.basicConfig(
level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
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]}...")
yield
logger.info("Media Server shutting down")
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(
title="Media Server",
description="REST API for controlling system media playback",
version="1.0.0",
lifespan=lifespan,
)
# Add CORS middleware for cross-origin requests
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(health_router)
app.include_router(media_router)
app.include_router(scripts_router)
return app
app = create_app()
def main():
"""Main entry point for running the server."""
parser = argparse.ArgumentParser(description="Media Server")
parser.add_argument(
"--host",
default=settings.host,
help=f"Host to bind to (default: {settings.host})",
)
parser.add_argument(
"--port",
type=int,
default=settings.port,
help=f"Port to bind to (default: {settings.port})",
)
parser.add_argument(
"--generate-config",
action="store_true",
help="Generate a default configuration file and exit",
)
parser.add_argument(
"--show-token",
action="store_true",
help="Show the current API token and exit",
)
args = parser.parse_args()
if args.generate_config:
config_path = generate_default_config()
print(f"Configuration file generated at: {config_path}")
print(f"API Token has been saved to the config file.")
return
if args.show_token:
print(f"API Token: {settings.api_token}")
print(f"Config directory: {get_config_dir()}")
return
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,17 @@
"""Pydantic models for the media server API."""
from .media import (
MediaState,
MediaStatus,
VolumeRequest,
SeekRequest,
MediaInfo,
)
__all__ = [
"MediaState",
"MediaStatus",
"VolumeRequest",
"SeekRequest",
"MediaInfo",
]

View File

@@ -0,0 +1,61 @@
"""Media-related Pydantic models."""
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class MediaState(str, Enum):
"""Playback state enumeration."""
PLAYING = "playing"
PAUSED = "paused"
STOPPED = "stopped"
IDLE = "idle"
class MediaInfo(BaseModel):
"""Information about the currently playing media."""
title: Optional[str] = Field(None, description="Track/media title")
artist: Optional[str] = Field(None, description="Artist name")
album: Optional[str] = Field(None, description="Album name")
album_art_url: Optional[str] = Field(None, description="URL to album artwork")
duration: Optional[float] = Field(
None, description="Total duration in seconds", ge=0
)
position: Optional[float] = Field(
None, description="Current position in seconds", ge=0
)
class MediaStatus(BaseModel):
"""Complete media playback status."""
state: MediaState = Field(default=MediaState.IDLE, description="Playback state")
title: Optional[str] = Field(None, description="Track/media title")
artist: Optional[str] = Field(None, description="Artist name")
album: Optional[str] = Field(None, description="Album name")
album_art_url: Optional[str] = Field(None, description="URL to album artwork")
duration: Optional[float] = Field(
None, description="Total duration in seconds", ge=0
)
position: Optional[float] = Field(
None, description="Current position in seconds", ge=0
)
volume: int = Field(default=100, description="Volume level (0-100)", ge=0, le=100)
muted: bool = Field(default=False, description="Whether audio is muted")
source: Optional[str] = Field(None, description="Media source/player name")
class VolumeRequest(BaseModel):
"""Request model for setting volume."""
volume: int = Field(..., description="Volume level (0-100)", ge=0, le=100)
class SeekRequest(BaseModel):
"""Request model for seeking to a position."""
position: float = Field(..., description="Position in seconds to seek to", ge=0)

View File

@@ -0,0 +1,28 @@
# Core dependencies
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
pydantic>=2.0
pydantic-settings>=2.0
pyyaml>=6.0
# Windows media control (install on Windows only)
# pip install winsdk pywin32 pycaw comtypes
winsdk>=1.0.0b10; sys_platform == "win32"
pywin32>=306; sys_platform == "win32"
comtypes>=1.2.0; sys_platform == "win32"
pycaw>=20230407; sys_platform == "win32"
# Linux media control (install on Linux only)
# pip install dbus-python PyGObject
# Note: dbus-python requires system dependencies:
# sudo apt-get install libdbus-1-dev libglib2.0-dev python3-gi
# dbus-python>=1.3.2; sys_platform == "linux"
# PyGObject>=3.46.0; sys_platform == "linux"
# macOS media control
# No additional dependencies needed - uses osascript (AppleScript)
# Android media control (via Termux)
# Requires Termux and Termux:API apps from F-Droid
# In Termux: pkg install python termux-api
# No additional pip packages needed

View File

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

View File

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

View File

@@ -0,0 +1,186 @@
"""Media control API endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response
from ..auth import verify_token, verify_token_or_query
from ..models import MediaStatus, VolumeRequest, SeekRequest
from ..services import get_media_controller, get_current_album_art
router = APIRouter(prefix="/api/media", tags=["media"])
@router.get("/status", response_model=MediaStatus)
async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus:
"""Get current media playback status.
Returns:
Current playback state, media info, volume, etc.
"""
controller = get_media_controller()
return await controller.get_status()
@router.post("/play")
async def play(_: str = Depends(verify_token)) -> dict:
"""Resume or start playback.
Returns:
Success status
"""
controller = get_media_controller()
success = await controller.play()
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to start playback - no active media session",
)
return {"success": True}
@router.post("/pause")
async def pause(_: str = Depends(verify_token)) -> dict:
"""Pause playback.
Returns:
Success status
"""
controller = get_media_controller()
success = await controller.pause()
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to pause - no active media session",
)
return {"success": True}
@router.post("/stop")
async def stop(_: str = Depends(verify_token)) -> dict:
"""Stop playback.
Returns:
Success status
"""
controller = get_media_controller()
success = await controller.stop()
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to stop - no active media session",
)
return {"success": True}
@router.post("/next")
async def next_track(_: str = Depends(verify_token)) -> dict:
"""Skip to next track.
Returns:
Success status
"""
controller = get_media_controller()
success = await controller.next_track()
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to skip - no active media session",
)
return {"success": True}
@router.post("/previous")
async def previous_track(_: str = Depends(verify_token)) -> dict:
"""Go to previous track.
Returns:
Success status
"""
controller = get_media_controller()
success = await controller.previous_track()
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to go back - no active media session",
)
return {"success": True}
@router.post("/volume")
async def set_volume(
request: VolumeRequest, _: str = Depends(verify_token)
) -> dict:
"""Set the system volume.
Args:
request: Volume level (0-100)
Returns:
Success status with new volume level
"""
controller = get_media_controller()
success = await controller.set_volume(request.volume)
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to set volume",
)
return {"success": True, "volume": request.volume}
@router.post("/mute")
async def toggle_mute(_: str = Depends(verify_token)) -> dict:
"""Toggle mute state.
Returns:
Success status with new mute state
"""
controller = get_media_controller()
muted = await controller.toggle_mute()
return {"success": True, "muted": muted}
@router.post("/seek")
async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict:
"""Seek to a position in the current track.
Args:
request: Position in seconds
Returns:
Success status
"""
controller = get_media_controller()
success = await controller.seek(request.position)
if not success:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to seek - no active media session or seek not supported",
)
return {"success": True, "position": request.position}
@router.get("/artwork")
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
"""Get the current album artwork.
Returns:
The album art image as PNG/JPEG
"""
art_bytes = get_current_album_art()
if art_bytes is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No album artwork available",
)
# Try to detect image type from magic bytes
content_type = "image/png" # Default
if art_bytes[:3] == b"\xff\xd8\xff":
content_type = "image/jpeg"
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
content_type = "image/png"
elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP":
content_type = "image/webp"
return Response(content=art_bytes, media_type=content_type)

View File

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

View File

@@ -0,0 +1,144 @@
#!/bin/bash
# Linux service installation script for Media Server
set -e
SERVICE_NAME="media-server"
INSTALL_DIR="/opt/media-server"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}@.service"
CURRENT_USER=$(whoami)
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
echo_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
echo_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_root() {
if [[ $EUID -ne 0 ]]; then
echo_error "This script must be run as root (use sudo)"
exit 1
fi
}
install_dependencies() {
echo_info "Installing system dependencies..."
if command -v apt-get &> /dev/null; then
apt-get update
apt-get install -y python3 python3-pip python3-venv python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
elif command -v dnf &> /dev/null; then
dnf install -y python3 python3-pip python3-dbus python3-gobject dbus-devel glib2-devel
elif command -v pacman &> /dev/null; then
pacman -S --noconfirm python python-pip python-dbus python-gobject
else
echo_warn "Unknown package manager. Please install dependencies manually:"
echo " - python3, python3-pip, python3-venv"
echo " - python3-dbus, python3-gi"
echo " - libdbus-1-dev, libglib2.0-dev"
fi
}
install_service() {
echo_info "Installing Media Server..."
# Create installation directory
mkdir -p "$INSTALL_DIR"
# Copy source files
cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/"
# Create virtual environment
echo_info "Creating Python virtual environment..."
python3 -m venv "$INSTALL_DIR/venv"
# Install Python dependencies
echo_info "Installing Python dependencies..."
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
# Install systemd service file
echo_info "Installing systemd service..."
cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE"
# Reload systemd
systemctl daemon-reload
# Generate config if not exists
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
echo_info "Generating configuration file..."
sudo -u "$SUDO_USER" "$INSTALL_DIR/venv/bin/python" -m media_server.main --generate-config
fi
echo_info "Installation complete!"
echo ""
echo "To enable and start the service for user '$SUDO_USER':"
echo " sudo systemctl enable ${SERVICE_NAME}@${SUDO_USER}"
echo " sudo systemctl start ${SERVICE_NAME}@${SUDO_USER}"
echo ""
echo "To view the API token:"
echo " cat ~/.config/media-server/config.yaml"
echo ""
echo "To view logs:"
echo " journalctl -u ${SERVICE_NAME}@${SUDO_USER} -f"
}
uninstall_service() {
echo_info "Uninstalling Media Server..."
# Stop and disable service
systemctl stop "${SERVICE_NAME}@*" 2>/dev/null || true
systemctl disable "${SERVICE_NAME}@*" 2>/dev/null || true
# Remove service file
rm -f "$SERVICE_FILE"
systemctl daemon-reload
# Remove installation directory
rm -rf "$INSTALL_DIR"
echo_info "Uninstallation complete!"
echo "Note: Configuration files in ~/.config/media-server were not removed."
}
show_usage() {
echo "Usage: $0 [install|uninstall|deps]"
echo ""
echo "Commands:"
echo " install Install the Media Server as a systemd service"
echo " uninstall Remove the Media Server service"
echo " deps Install system dependencies only"
}
# Main
case "${1:-}" in
install)
check_root
install_dependencies
install_service
;;
uninstall)
check_root
uninstall_service
;;
deps)
check_root
install_dependencies
;;
*)
show_usage
exit 1
;;
esac

View File

@@ -0,0 +1,10 @@
# Get the project root directory (two levels up from this script)
$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName
$action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant"
Write-Host "Scheduled task 'MediaServer' created with working directory: $projectRoot"

View File

@@ -0,0 +1,151 @@
"""Windows service installer for Media Server.
This module allows the media server to be installed as a Windows service
that starts automatically on boot.
Usage:
Install: python -m media_server.service.install_windows install
Start: python -m media_server.service.install_windows start
Stop: python -m media_server.service.install_windows stop
Remove: python -m media_server.service.install_windows remove
Debug: python -m media_server.service.install_windows debug
"""
import os
import sys
import socket
import logging
try:
import win32serviceutil
import win32service
import win32event
import servicemanager
import win32api
WIN32_AVAILABLE = True
except ImportError:
WIN32_AVAILABLE = False
print("pywin32 not installed. Install with: pip install pywin32")
class MediaServerService:
"""Windows service wrapper for the Media Server."""
_svc_name_ = "MediaServer"
_svc_display_name_ = "Media Server"
_svc_description_ = "REST API server for controlling system media playback"
def __init__(self, args=None):
if WIN32_AVAILABLE:
win32serviceutil.ServiceFramework.__init__(self, args)
self.stop_event = win32event.CreateEvent(None, 0, 0, None)
self.is_running = False
self.server = None
def SvcStop(self):
"""Stop the service."""
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
win32event.SetEvent(self.stop_event)
self.is_running = False
if self.server:
self.server.should_exit = True
def SvcDoRun(self):
"""Run the service."""
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STARTED,
(self._svc_name_, ""),
)
self.is_running = True
self.main()
def main(self):
"""Main service loop."""
import uvicorn
from media_server.main import app
from media_server.config import settings
config = uvicorn.Config(
app,
host=settings.host,
port=settings.port,
log_level=settings.log_level.lower(),
)
self.server = uvicorn.Server(config)
self.server.run()
if WIN32_AVAILABLE:
# Dynamically inherit from ServiceFramework when available
MediaServerService = type(
"MediaServerService",
(win32serviceutil.ServiceFramework,),
dict(MediaServerService.__dict__),
)
def install_service():
"""Install the Windows service."""
if not WIN32_AVAILABLE:
print("Error: pywin32 is required for Windows service installation")
print("Install with: pip install pywin32")
return False
try:
# Get the path to the Python executable
python_exe = sys.executable
# Get the path to this module
module_path = os.path.abspath(__file__)
win32serviceutil.InstallService(
MediaServerService._svc_name_,
MediaServerService._svc_name_,
MediaServerService._svc_display_name_,
startType=win32service.SERVICE_AUTO_START,
description=MediaServerService._svc_description_,
)
print(f"Service '{MediaServerService._svc_display_name_}' installed successfully")
print("Start the service with: sc start MediaServer")
return True
except Exception as e:
print(f"Failed to install service: {e}")
return False
def remove_service():
"""Remove the Windows service."""
if not WIN32_AVAILABLE:
print("Error: pywin32 is required")
return False
try:
win32serviceutil.RemoveService(MediaServerService._svc_name_)
print(f"Service '{MediaServerService._svc_display_name_}' removed successfully")
return True
except Exception as e:
print(f"Failed to remove service: {e}")
return False
def main():
"""Main entry point for service management."""
if not WIN32_AVAILABLE:
print("Error: pywin32 is required for Windows service support")
print("Install with: pip install pywin32")
sys.exit(1)
if len(sys.argv) == 1:
# Running as a service
servicemanager.Initialize()
servicemanager.PrepareToHostSingle(MediaServerService)
servicemanager.StartServiceCtrlDispatcher()
else:
# Command line management
win32serviceutil.HandleCommandLine(MediaServerService)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
[Unit]
Description=Media Server - REST API for controlling system media playback
After=network.target sound.target
Wants=sound.target
[Service]
Type=simple
User=%i
Group=%i
# Environment variables (optional - can also use config file)
# Environment=MEDIA_SERVER_HOST=0.0.0.0
# Environment=MEDIA_SERVER_PORT=8765
# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
# Working directory
WorkingDirectory=/opt/media-server
# Start command - adjust path to your Python environment
ExecStart=/opt/media-server/venv/bin/python -m media_server.main
# Restart policy
Restart=always
RestartSec=10
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=true
# Required for D-Bus access (MPRIS)
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,75 @@
"""Media controller services."""
import os
import platform
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .media_controller import MediaController
_controller_instance: "MediaController | None" = None
def _is_android() -> bool:
"""Check if running on Android (e.g., via Termux)."""
# Check for Android-specific paths and environment
android_indicators = [
Path("/system/build.prop").exists(),
Path("/data/data/com.termux").exists(),
"ANDROID_ROOT" in os.environ,
"TERMUX_VERSION" in os.environ,
]
return any(android_indicators)
def get_media_controller() -> "MediaController":
"""Get the platform-specific media controller instance.
Returns:
The media controller for the current platform
Raises:
RuntimeError: If the platform is not supported
"""
global _controller_instance
if _controller_instance is not None:
return _controller_instance
system = platform.system()
if system == "Windows":
from .windows_media import WindowsMediaController
_controller_instance = WindowsMediaController()
elif system == "Linux":
# Check if running on Android
if _is_android():
from .android_media import AndroidMediaController
_controller_instance = AndroidMediaController()
else:
from .linux_media import LinuxMediaController
_controller_instance = LinuxMediaController()
elif system == "Darwin": # macOS
from .macos_media import MacOSMediaController
_controller_instance = MacOSMediaController()
else:
raise RuntimeError(f"Unsupported platform: {system}")
return _controller_instance
def get_current_album_art() -> bytes | None:
"""Get the current album art bytes (Windows only for now)."""
system = platform.system()
if system == "Windows":
from .windows_media import get_current_album_art as _get_art
return _get_art()
return None
__all__ = ["get_media_controller", "get_current_album_art"]

View File

@@ -0,0 +1,232 @@
"""Android media controller using Termux:API.
This controller is designed to run on Android devices using Termux.
It requires the Termux:API app and termux-api package to be installed.
Installation:
1. Install Termux from F-Droid (not Play Store)
2. Install Termux:API from F-Droid
3. In Termux: pkg install termux-api
4. Grant necessary permissions to Termux:API
"""
import asyncio
import json
import logging
import subprocess
from typing import Optional, Any
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
logger = logging.getLogger(__name__)
def _check_termux_api() -> bool:
"""Check if termux-api is available."""
try:
result = subprocess.run(
["which", "termux-media-player"],
capture_output=True,
timeout=5,
)
return result.returncode == 0
except Exception:
return False
TERMUX_API_AVAILABLE = _check_termux_api()
class AndroidMediaController(MediaController):
"""Media controller for Android using Termux:API.
Requires:
- Termux app
- Termux:API app
- termux-api package (pkg install termux-api)
"""
def __init__(self):
if not TERMUX_API_AVAILABLE:
logger.warning(
"Termux:API not available. Install with: pkg install termux-api"
)
def _run_termux_command(
self, command: list[str], timeout: int = 10
) -> Optional[str]:
"""Run a termux-api command and return the output."""
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode == 0:
return result.stdout.strip()
logger.error(f"Termux command failed: {result.stderr}")
return None
except subprocess.TimeoutExpired:
logger.error(f"Termux command timed out: {command}")
return None
except Exception as e:
logger.error(f"Termux command error: {e}")
return None
def _send_media_key(self, key: str) -> bool:
"""Send a media key event.
Args:
key: One of: play, pause, play-pause, stop, next, previous
"""
# termux-media-player command
result = self._run_termux_command(["termux-media-player", key])
return result is not None
def _get_media_info(self) -> dict[str, Any]:
"""Get current media playback info using termux-media-player."""
result = self._run_termux_command(["termux-media-player", "info"])
if result:
try:
return json.loads(result)
except json.JSONDecodeError:
pass
return {}
def _get_volume(self) -> tuple[int, bool]:
"""Get current volume using termux-volume."""
result = self._run_termux_command(["termux-volume"])
if result:
try:
volumes = json.loads(result)
# Find music stream
for stream in volumes:
if stream.get("stream") == "music":
volume = stream.get("volume", 0)
max_volume = stream.get("max_volume", 15)
# Convert to 0-100 scale
percent = int((volume / max_volume) * 100) if max_volume > 0 else 0
return percent, False
except (json.JSONDecodeError, KeyError):
pass
return 100, False
def _set_volume_internal(self, volume: int) -> bool:
"""Set volume using termux-volume."""
# termux-volume expects stream name and volume level
# Convert 0-100 to device scale (usually 0-15)
result = self._run_termux_command(["termux-volume"])
if result:
try:
volumes = json.loads(result)
for stream in volumes:
if stream.get("stream") == "music":
max_volume = stream.get("max_volume", 15)
device_volume = int((volume / 100) * max_volume)
self._run_termux_command(
["termux-volume", "music", str(device_volume)]
)
return True
except (json.JSONDecodeError, KeyError):
pass
return False
async def get_status(self) -> MediaStatus:
"""Get current media playback status."""
status = MediaStatus()
# Get volume
volume, muted = self._get_volume()
status.volume = volume
status.muted = muted
# Get media info
info = self._get_media_info()
if not info:
status.state = MediaState.IDLE
return status
# Parse playback status
playback_status = info.get("status", "").lower()
if playback_status == "playing":
status.state = MediaState.PLAYING
elif playback_status == "paused":
status.state = MediaState.PAUSED
elif playback_status == "stopped":
status.state = MediaState.STOPPED
else:
status.state = MediaState.IDLE
# Parse track info
status.title = info.get("title") or info.get("Track") or None
status.artist = info.get("artist") or info.get("Artist") or None
status.album = info.get("album") or info.get("Album") or None
# Duration and position (in milliseconds from some sources)
duration = info.get("duration", 0)
if duration > 1000: # Likely milliseconds
duration = duration / 1000
status.duration = duration if duration > 0 else None
position = info.get("position", info.get("current_position", 0))
if position > 1000: # Likely milliseconds
position = position / 1000
status.position = position if position > 0 else None
status.source = "Android"
return status
async def play(self) -> bool:
"""Resume playback."""
return self._send_media_key("play")
async def pause(self) -> bool:
"""Pause playback."""
return self._send_media_key("pause")
async def stop(self) -> bool:
"""Stop playback."""
return self._send_media_key("stop")
async def next_track(self) -> bool:
"""Skip to next track."""
return self._send_media_key("next")
async def previous_track(self) -> bool:
"""Go to previous track."""
return self._send_media_key("previous")
async def set_volume(self, volume: int) -> bool:
"""Set system volume."""
return self._set_volume_internal(volume)
async def toggle_mute(self) -> bool:
"""Toggle mute state.
Note: Android doesn't have a simple mute toggle via termux-api,
so we set volume to 0 or restore previous volume.
"""
volume, _ = self._get_volume()
if volume > 0:
# Store current volume and mute
self._previous_volume = volume
self._set_volume_internal(0)
return True
else:
# Restore previous volume
prev = getattr(self, "_previous_volume", 50)
self._set_volume_internal(prev)
return False
async def seek(self, position: float) -> bool:
"""Seek to position in seconds.
Note: Seek functionality may be limited depending on the media player.
"""
# termux-media-player doesn't support seek directly
# This is a limitation of the API
logger.warning("Seek not fully supported on Android via Termux:API")
return False

View File

@@ -0,0 +1,295 @@
"""Linux media controller using MPRIS D-Bus interface."""
import asyncio
import logging
import subprocess
from typing import Optional, Any
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
logger = logging.getLogger(__name__)
# Linux-specific imports
try:
import dbus
from dbus.mainloop.glib import DBusGMainLoop
DBUS_AVAILABLE = True
except ImportError:
DBUS_AVAILABLE = False
logger.warning("D-Bus libraries not available")
class LinuxMediaController(MediaController):
"""Media controller for Linux using MPRIS D-Bus interface."""
MPRIS_PATH = "/org/mpris/MediaPlayer2"
MPRIS_INTERFACE = "org.mpris.MediaPlayer2.Player"
MPRIS_PREFIX = "org.mpris.MediaPlayer2."
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
def __init__(self):
if not DBUS_AVAILABLE:
raise RuntimeError(
"Linux media control requires dbus-python package. "
"Install with: sudo apt-get install python3-dbus"
)
DBusGMainLoop(set_as_default=True)
self._bus = dbus.SessionBus()
def _get_active_player(self) -> Optional[str]:
"""Find an active MPRIS media player on the bus."""
try:
bus_names = self._bus.list_names()
mpris_players = [
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
]
if not mpris_players:
return None
# Prefer players that are currently playing
for player in mpris_players:
try:
proxy = self._bus.get_object(player, self.MPRIS_PATH)
props = dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
status = props.Get(self.MPRIS_INTERFACE, "PlaybackStatus")
if status == "Playing":
return player
except Exception:
continue
# Return the first available player
return mpris_players[0]
except Exception as e:
logger.error(f"Failed to get active player: {e}")
return None
def _get_player_interface(self, player_name: str):
"""Get the MPRIS player interface."""
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
return dbus.Interface(proxy, self.MPRIS_INTERFACE)
def _get_properties_interface(self, player_name: str):
"""Get the properties interface for a player."""
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
return dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
def _get_property(self, player_name: str, property_name: str) -> Any:
"""Get a property from the player."""
try:
props = self._get_properties_interface(player_name)
return props.Get(self.MPRIS_INTERFACE, property_name)
except Exception as e:
logger.debug(f"Failed to get property {property_name}: {e}")
return None
def _get_volume_pulseaudio(self) -> tuple[int, bool]:
"""Get volume using pactl (PulseAudio/PipeWire)."""
try:
# Get default sink volume
result = subprocess.run(
["pactl", "get-sink-volume", "@DEFAULT_SINK@"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
# Parse volume from output like "Volume: front-left: 65536 / 100% / 0.00 dB"
for part in result.stdout.split("/"):
if "%" in part:
volume = int(part.strip().rstrip("%"))
break
else:
volume = 100
else:
volume = 100
# Get mute status
result = subprocess.run(
["pactl", "get-sink-mute", "@DEFAULT_SINK@"],
capture_output=True,
text=True,
timeout=5,
)
muted = "yes" in result.stdout.lower() if result.returncode == 0 else False
return volume, muted
except Exception as e:
logger.error(f"Failed to get volume via pactl: {e}")
return 100, False
def _set_volume_pulseaudio(self, volume: int) -> bool:
"""Set volume using pactl."""
try:
result = subprocess.run(
["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{volume}%"],
capture_output=True,
timeout=5,
)
return result.returncode == 0
except Exception as e:
logger.error(f"Failed to set volume: {e}")
return False
def _toggle_mute_pulseaudio(self) -> bool:
"""Toggle mute using pactl, returns new mute state."""
try:
result = subprocess.run(
["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"],
capture_output=True,
timeout=5,
)
if result.returncode == 0:
_, muted = self._get_volume_pulseaudio()
return muted
return False
except Exception as e:
logger.error(f"Failed to toggle mute: {e}")
return False
async def get_status(self) -> MediaStatus:
"""Get current media playback status."""
status = MediaStatus()
# Get system volume
volume, muted = self._get_volume_pulseaudio()
status.volume = volume
status.muted = muted
# Get active player
player_name = self._get_active_player()
if player_name is None:
status.state = MediaState.IDLE
return status
# Get playback status
playback_status = self._get_property(player_name, "PlaybackStatus")
if playback_status == "Playing":
status.state = MediaState.PLAYING
elif playback_status == "Paused":
status.state = MediaState.PAUSED
elif playback_status == "Stopped":
status.state = MediaState.STOPPED
else:
status.state = MediaState.IDLE
# Get metadata
metadata = self._get_property(player_name, "Metadata")
if metadata:
status.title = str(metadata.get("xesam:title", "")) or None
artists = metadata.get("xesam:artist", [])
if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) or None
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
# Duration in microseconds
length = metadata.get("mpris:length", 0)
if length:
status.duration = int(length) / 1_000_000
# Get position (in microseconds)
position = self._get_property(player_name, "Position")
if position is not None:
status.position = int(position) / 1_000_000
# Get source name
status.source = player_name.replace(self.MPRIS_PREFIX, "")
return status
async def play(self) -> bool:
"""Resume playback."""
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Play()
return True
except Exception as e:
logger.error(f"Failed to play: {e}")
return False
async def pause(self) -> bool:
"""Pause playback."""
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Pause()
return True
except Exception as e:
logger.error(f"Failed to pause: {e}")
return False
async def stop(self) -> bool:
"""Stop playback."""
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Stop()
return True
except Exception as e:
logger.error(f"Failed to stop: {e}")
return False
async def next_track(self) -> bool:
"""Skip to next track."""
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Next()
return True
except Exception as e:
logger.error(f"Failed to skip next: {e}")
return False
async def previous_track(self) -> bool:
"""Go to previous track."""
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
player.Previous()
return True
except Exception as e:
logger.error(f"Failed to skip previous: {e}")
return False
async def set_volume(self, volume: int) -> bool:
"""Set system volume."""
return self._set_volume_pulseaudio(volume)
async def toggle_mute(self) -> bool:
"""Toggle mute state."""
return self._toggle_mute_pulseaudio()
async def seek(self, position: float) -> bool:
"""Seek to position in seconds."""
player_name = self._get_active_player()
if player_name is None:
return False
try:
player = self._get_player_interface(player_name)
# MPRIS expects position in microseconds
player.SetPosition(
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
int(position * 1_000_000),
)
return True
except Exception as e:
logger.error(f"Failed to seek: {e}")
return False

View File

@@ -0,0 +1,296 @@
"""macOS media controller using AppleScript and system commands."""
import asyncio
import logging
import subprocess
import json
from typing import Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
logger = logging.getLogger(__name__)
class MacOSMediaController(MediaController):
"""Media controller for macOS using osascript and system commands."""
def _run_osascript(self, script: str) -> Optional[str]:
"""Run an AppleScript and return the output."""
try:
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
return None
except Exception as e:
logger.error(f"osascript error: {e}")
return None
def _get_active_app(self) -> Optional[str]:
"""Get the currently active media application."""
# Check common media apps in order of preference
apps = ["Spotify", "Music", "TV", "VLC", "QuickTime Player"]
for app in apps:
script = f'''
tell application "System Events"
if exists (processes where name is "{app}") then
return "{app}"
end if
end tell
return ""
'''
result = self._run_osascript(script)
if result:
return result
return None
def _get_spotify_info(self) -> dict:
"""Get playback info from Spotify."""
script = '''
tell application "Spotify"
if player state is playing then
set currentState to "playing"
else if player state is paused then
set currentState to "paused"
else
set currentState to "stopped"
end if
try
set trackName to name of current track
set artistName to artist of current track
set albumName to album of current track
set trackDuration to duration of current track
set trackPosition to player position
set artUrl to artwork url of current track
on error
set trackName to ""
set artistName to ""
set albumName to ""
set trackDuration to 0
set trackPosition to 0
set artUrl to ""
end try
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition & "|" & artUrl
end tell
'''
result = self._run_osascript(script)
if result:
parts = result.split("|")
if len(parts) >= 7:
return {
"state": parts[0],
"title": parts[1] or None,
"artist": parts[2] or None,
"album": parts[3] or None,
"duration": float(parts[4]) / 1000 if parts[4] else None, # ms to seconds
"position": float(parts[5]) if parts[5] else None,
"art_url": parts[6] or None,
}
return {}
def _get_music_info(self) -> dict:
"""Get playback info from Apple Music."""
script = '''
tell application "Music"
if player state is playing then
set currentState to "playing"
else if player state is paused then
set currentState to "paused"
else
set currentState to "stopped"
end if
try
set trackName to name of current track
set artistName to artist of current track
set albumName to album of current track
set trackDuration to duration of current track
set trackPosition to player position
on error
set trackName to ""
set artistName to ""
set albumName to ""
set trackDuration to 0
set trackPosition to 0
end try
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition
end tell
'''
result = self._run_osascript(script)
if result:
parts = result.split("|")
if len(parts) >= 6:
return {
"state": parts[0],
"title": parts[1] or None,
"artist": parts[2] or None,
"album": parts[3] or None,
"duration": float(parts[4]) if parts[4] else None,
"position": float(parts[5]) if parts[5] else None,
}
return {}
def _get_volume(self) -> tuple[int, bool]:
"""Get system volume and mute state."""
try:
# Get volume level
result = self._run_osascript("output volume of (get volume settings)")
volume = int(result) if result else 100
# Get mute state
result = self._run_osascript("output muted of (get volume settings)")
muted = result == "true"
return volume, muted
except Exception as e:
logger.error(f"Failed to get volume: {e}")
return 100, False
async def get_status(self) -> MediaStatus:
"""Get current media playback status."""
status = MediaStatus()
# Get system volume
volume, muted = self._get_volume()
status.volume = volume
status.muted = muted
# Try to get info from active media app
active_app = self._get_active_app()
if active_app is None:
status.state = MediaState.IDLE
return status
status.source = active_app
if active_app == "Spotify":
info = self._get_spotify_info()
elif active_app == "Music":
info = self._get_music_info()
else:
info = {}
if info:
state = info.get("state", "stopped")
if state == "playing":
status.state = MediaState.PLAYING
elif state == "paused":
status.state = MediaState.PAUSED
else:
status.state = MediaState.STOPPED
status.title = info.get("title")
status.artist = info.get("artist")
status.album = info.get("album")
status.duration = info.get("duration")
status.position = info.get("position")
status.album_art_url = info.get("art_url")
else:
status.state = MediaState.IDLE
return status
async def play(self) -> bool:
"""Resume playback using media key simulation."""
# Use system media key
script = '''
tell application "System Events"
key code 16 using {command down, option down}
end tell
'''
# Fallback: try specific app
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to play')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to play')
return True
# Use media key simulation
result = subprocess.run(
["osascript", "-e", 'tell application "System Events" to key code 49'],
capture_output=True,
)
return result.returncode == 0
async def pause(self) -> bool:
"""Pause playback."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to pause')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to pause')
return True
return False
async def stop(self) -> bool:
"""Stop playback."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to pause')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to stop')
return True
return False
async def next_track(self) -> bool:
"""Skip to next track."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to next track')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to next track')
return True
return False
async def previous_track(self) -> bool:
"""Go to previous track."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript('tell application "Spotify" to previous track')
return True
elif active_app == "Music":
self._run_osascript('tell application "Music" to previous track')
return True
return False
async def set_volume(self, volume: int) -> bool:
"""Set system volume."""
result = self._run_osascript(f"set volume output volume {volume}")
return result is not None or True # osascript returns empty on success
async def toggle_mute(self) -> bool:
"""Toggle mute state."""
_, current_mute = self._get_volume()
new_mute = not current_mute
self._run_osascript(f"set volume output muted {str(new_mute).lower()}")
return new_mute
async def seek(self, position: float) -> bool:
"""Seek to position in seconds."""
active_app = self._get_active_app()
if active_app == "Spotify":
self._run_osascript(
f'tell application "Spotify" to set player position to {position}'
)
return True
elif active_app == "Music":
self._run_osascript(
f'tell application "Music" to set player position to {position}'
)
return True
return False

View File

@@ -0,0 +1,96 @@
"""Abstract base class for media controllers."""
from abc import ABC, abstractmethod
from ..models import MediaStatus
class MediaController(ABC):
"""Abstract base class for platform-specific media controllers."""
@abstractmethod
async def get_status(self) -> MediaStatus:
"""Get the current media playback status.
Returns:
MediaStatus with current playback info
"""
pass
@abstractmethod
async def play(self) -> bool:
"""Resume or start playback.
Returns:
True if successful, False otherwise
"""
pass
@abstractmethod
async def pause(self) -> bool:
"""Pause playback.
Returns:
True if successful, False otherwise
"""
pass
@abstractmethod
async def stop(self) -> bool:
"""Stop playback.
Returns:
True if successful, False otherwise
"""
pass
@abstractmethod
async def next_track(self) -> bool:
"""Skip to the next track.
Returns:
True if successful, False otherwise
"""
pass
@abstractmethod
async def previous_track(self) -> bool:
"""Go to the previous track.
Returns:
True if successful, False otherwise
"""
pass
@abstractmethod
async def set_volume(self, volume: int) -> bool:
"""Set the system volume.
Args:
volume: Volume level (0-100)
Returns:
True if successful, False otherwise
"""
pass
@abstractmethod
async def toggle_mute(self) -> bool:
"""Toggle the mute state.
Returns:
The new mute state (True = muted)
"""
pass
@abstractmethod
async def seek(self, position: float) -> bool:
"""Seek to a position in the current track.
Args:
position: Position in seconds
Returns:
True if successful, False otherwise
"""
pass

View File

@@ -0,0 +1,596 @@
"""Windows media controller using WinRT APIs."""
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
logger = logging.getLogger(__name__)
# Thread pool for WinRT operations (they don't play well with asyncio)
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None
# Global storage for position tracking
import time as _time
_position_cache = {
"track_id": "",
"base_position": 0.0,
"base_time": 0.0,
"is_playing": False,
"duration": 0.0,
}
# Flag to force position to 0 after track skip (until title changes)
_track_skip_pending = {
"active": False,
"old_title": "",
"skip_time": 0.0,
"grace_until": 0.0, # After title changes, ignore stale SMTC positions
"stale_pos": -999, # The stale SMTC position we're ignoring
}
def get_current_album_art() -> bytes | None:
"""Get the current album art bytes."""
return _current_album_art_bytes
# Windows-specific imports
try:
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
)
WINSDK_AVAILABLE = True
except ImportError:
WINSDK_AVAILABLE = False
logger.warning("winsdk not available")
# Volume control imports
PYCAW_AVAILABLE = False
_volume_control = None
try:
from ctypes import cast, POINTER
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
def _init_volume_control():
"""Initialize volume control interface."""
global _volume_control
if _volume_control is not None:
return _volume_control
try:
devices = AudioUtilities.GetSpeakers()
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
return _volume_control
except AttributeError:
# Try accessing the underlying device
try:
devices = AudioUtilities.GetSpeakers()
if hasattr(devices, '_dev'):
interface = devices._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
return _volume_control
except Exception as e:
logger.debug(f"Volume control init failed: {e}")
except Exception as e:
logger.debug(f"Volume control init error: {e}")
return None
PYCAW_AVAILABLE = True
except ImportError as e:
logger.warning(f"pycaw not available: {e}")
def _init_volume_control():
return None
WINDOWS_AVAILABLE = WINSDK_AVAILABLE
def _sync_get_media_status() -> dict[str, Any]:
"""Synchronously get media status (runs in thread pool)."""
import asyncio
result = {
"state": "idle",
"title": None,
"artist": None,
"album": None,
"duration": None,
"position": None,
"source": None,
}
try:
# Create a new event loop for this thread
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# Get media session manager
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return result
session = _find_best_session(manager, loop)
if session is None:
return result
# Get playback status
playback_info = session.get_playback_info()
if playback_info:
status = playback_info.playback_status
if status == PlaybackStatus.PLAYING:
result["state"] = "playing"
elif status == PlaybackStatus.PAUSED:
result["state"] = "paused"
elif status == PlaybackStatus.STOPPED:
result["state"] = "stopped"
# Get media properties FIRST (needed for track ID)
media_props = loop.run_until_complete(
session.try_get_media_properties_async()
)
if media_props:
result["title"] = media_props.title or None
result["artist"] = media_props.artist or None
result["album"] = media_props.album_title or None
# Get timeline
timeline = session.get_timeline_properties()
if timeline:
try:
# end_time and position are datetime.timedelta objects
end_time = timeline.end_time
position = timeline.position
# Get duration
if hasattr(end_time, 'total_seconds'):
duration = end_time.total_seconds()
# Sanity check: duration should be positive and reasonable (< 24 hours)
if 0 < duration < 86400:
result["duration"] = duration
# Get position from SMTC and interpolate for smooth updates
if hasattr(position, 'total_seconds'):
smtc_pos = position.total_seconds()
current_time = _time.time()
is_playing = result["state"] == "playing"
current_title = result.get('title', '')
# Check if track skip is pending and title changed
skip_just_completed = False
if _track_skip_pending["active"]:
if current_title and current_title != _track_skip_pending["old_title"]:
# Title changed - clear the skip flag and start grace period
_track_skip_pending["active"] = False
_track_skip_pending["old_title"] = ""
_track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
skip_just_completed = True
# Reset position cache for new track
new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
_position_cache["track_id"] = new_track_id
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
logger.debug("Track skip timeout")
# Check if we're in grace period (after skip, ignore high SMTC positions)
in_grace_period = current_time < _track_skip_pending.get("grace_until", 0)
# If track skip is pending or just completed, use cached/reset position
if _track_skip_pending["active"]:
pos = 0.0
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
elif skip_just_completed:
# Just completed skip - interpolate from 0
if is_playing:
elapsed = current_time - _position_cache["base_time"]
pos = elapsed
else:
pos = 0.0
elif in_grace_period:
# Grace period after track skip
# SMTC position is stale (from old track) and won't update until seek/pause
# We interpolate from 0 and only trust SMTC when it changes or reports low value
# Calculate interpolated position from start of new track
if is_playing:
elapsed = current_time - _position_cache.get("base_time", current_time)
interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed
else:
interpolated_pos = _position_cache.get("base_position", 0.0)
# Get the stale position we've been tracking
stale_pos = _track_skip_pending.get("stale_pos", -999)
# Detect if SMTC position changed significantly from the stale value (user seeked)
smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0
# Trust SMTC if:
# 1. It reports a low position (indicating new track started)
# 2. It changed from the stale value (user seeked)
if smtc_pos < 10.0 or smtc_changed:
# SMTC is now trustworthy
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["is_playing"] = is_playing
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
# Record the stale position for change detection
if stale_pos < 0:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
else:
# Normal position tracking
# Create track ID from title + artist + duration
track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
# Detect if SMTC position changed (new track, seek, or state change)
smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5
track_changed = track_id != _position_cache.get("track_id", "")
if smtc_pos_changed or track_changed:
# SMTC updated - store new baseline
_position_cache["track_id"] = track_id
_position_cache["last_smtc_pos"] = smtc_pos
_position_cache["base_position"] = smtc_pos
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
pos = smtc_pos
elif is_playing:
# Interpolate position based on elapsed time
elapsed = current_time - _position_cache.get("base_time", current_time)
pos = _position_cache.get("base_position", smtc_pos) + elapsed
else:
# Paused - use base position
pos = _position_cache.get("base_position", smtc_pos)
# Update playing state
if _position_cache.get("is_playing") != is_playing:
_position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
# Sanity check: position should be non-negative and <= duration
if pos >= 0:
if result["duration"] and pos <= result["duration"]:
result["position"] = pos
elif result["duration"] and pos > result["duration"]:
result["position"] = result["duration"]
elif not result["duration"]:
result["position"] = pos
logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}")
except Exception as e:
logger.debug(f"Timeline parse error: {e}")
# Try to get album art (requires media_props)
if media_props:
try:
thumbnail = media_props.thumbnail
if thumbnail:
stream = loop.run_until_complete(thumbnail.open_read_async())
if stream:
size = stream.size
if size > 0 and size < 10 * 1024 * 1024: # Max 10MB
from winsdk.windows.storage.streams import DataReader
reader = DataReader(stream)
loop.run_until_complete(reader.load_async(size))
buffer = bytearray(size)
reader.read_bytes(buffer)
reader.close()
stream.close()
global _current_album_art_bytes
_current_album_art_bytes = bytes(buffer)
result["album_art_url"] = "/api/media/artwork"
except Exception as e:
logger.debug(f"Failed to get album art: {e}")
result["source"] = session.source_app_user_model_id
finally:
loop.close()
except Exception as e:
logger.error(f"Error getting media status: {e}")
return result
def _find_best_session(manager, loop):
"""Find the best media session to control."""
# First try the current session
session = manager.get_current_session()
# Log all available sessions for debugging
sessions = manager.get_sessions()
if sessions:
logger.debug(f"Total sessions available: {sessions.size}")
for i in range(sessions.size):
s = sessions.get_at(i)
if s:
playback_info = s.get_playback_info()
status_name = "unknown"
if playback_info:
status_name = str(playback_info.playback_status)
logger.debug(f" Session {i}: {s.source_app_user_model_id} - status: {status_name}")
# If no current session, try to find any active session
if session is None:
if sessions and sessions.size > 0:
# Find a playing session, or use the first one
for i in range(sessions.size):
s = sessions.get_at(i)
if s:
playback_info = s.get_playback_info()
if playback_info and playback_info.playback_status == PlaybackStatus.PLAYING:
session = s
break
# If no playing session found, use the first available one
if session is None and sessions.size > 0:
session = sessions.get_at(0)
return session
def _sync_media_command(command: str) -> bool:
"""Synchronously execute a media command (runs in thread pool)."""
import asyncio
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
session = _find_best_session(manager, loop)
if session is None:
return False
if command == "play":
return loop.run_until_complete(session.try_play_async())
elif command == "pause":
return loop.run_until_complete(session.try_pause_async())
elif command == "stop":
return loop.run_until_complete(session.try_stop_async())
elif command == "next":
return loop.run_until_complete(session.try_skip_next_async())
elif command == "previous":
return loop.run_until_complete(session.try_skip_previous_async())
return False
finally:
loop.close()
except Exception as e:
logger.error(f"Error executing media command {command}: {e}")
return False
def _sync_seek(position: float) -> bool:
"""Synchronously seek to position."""
import asyncio
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return False
session = _find_best_session(manager, loop)
if session is None:
return False
position_ticks = int(position * 10_000_000)
return loop.run_until_complete(
session.try_change_playback_position_async(position_ticks)
)
finally:
loop.close()
except Exception as e:
logger.error(f"Error seeking: {e}")
return False
class WindowsMediaController(MediaController):
"""Media controller for Windows using WinRT and pycaw."""
def __init__(self):
if not WINDOWS_AVAILABLE:
raise RuntimeError(
"Windows media control requires winsdk, pycaw, and comtypes packages"
)
self._volume_interface = None
self._volume_init_attempted = False
def _get_volume_interface(self):
"""Get the audio endpoint volume interface."""
if not self._volume_init_attempted:
self._volume_init_attempted = True
self._volume_interface = _init_volume_control()
if self._volume_interface:
logger.info("Volume control initialized successfully")
else:
logger.warning("Volume control not available")
return self._volume_interface
async def get_status(self) -> MediaStatus:
"""Get current media playback status."""
status = MediaStatus()
# Get volume info (synchronous, fast)
volume_if = self._get_volume_interface()
if volume_if:
try:
volume_scalar = volume_if.GetMasterVolumeLevelScalar()
status.volume = int(volume_scalar * 100)
status.muted = bool(volume_if.GetMute())
except Exception as e:
logger.debug(f"Failed to get volume: {e}")
# Get media info in thread pool (avoids asyncio/WinRT issues)
try:
loop = asyncio.get_event_loop()
media_info = await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_get_media_status),
timeout=5.0
)
state_map = {
"playing": MediaState.PLAYING,
"paused": MediaState.PAUSED,
"stopped": MediaState.STOPPED,
"idle": MediaState.IDLE,
}
status.state = state_map.get(media_info.get("state", "idle"), MediaState.IDLE)
status.title = media_info.get("title")
status.artist = media_info.get("artist")
status.album = media_info.get("album")
status.album_art_url = media_info.get("album_art_url")
status.duration = media_info.get("duration")
status.position = media_info.get("position")
status.source = media_info.get("source")
except asyncio.TimeoutError:
logger.warning("Media status request timed out")
status.state = MediaState.IDLE
except Exception as e:
logger.error(f"Error getting media status: {e}")
status.state = MediaState.IDLE
return status
async def _run_command(self, command: str) -> bool:
"""Run a media command in the thread pool."""
try:
loop = asyncio.get_event_loop()
return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_media_command, command),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning(f"Media command {command} timed out")
return False
except Exception as e:
logger.error(f"Error running media command {command}: {e}")
return False
async def play(self) -> bool:
"""Resume playback."""
return await self._run_command("play")
async def pause(self) -> bool:
"""Pause playback."""
return await self._run_command("pause")
async def stop(self) -> bool:
"""Stop playback."""
return await self._run_command("stop")
async def next_track(self) -> bool:
"""Skip to next track."""
# Get current title before skipping
try:
status = await self.get_status()
old_title = status.title or ""
except Exception:
old_title = ""
result = await self._run_command("next")
if result:
# Set flag to force position to 0 until title changes
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}")
return result
async def previous_track(self) -> bool:
"""Go to previous track."""
# Get current title before skipping
try:
status = await self.get_status()
old_title = status.title or ""
except Exception:
old_title = ""
result = await self._run_command("previous")
if result:
# Set flag to force position to 0 until title changes
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
logger.debug(f"Track skip initiated, old title: {old_title}")
return result
async def set_volume(self, volume: int) -> bool:
"""Set system volume."""
volume_if = self._get_volume_interface()
if volume_if is None:
return False
try:
volume_if.SetMasterVolumeLevelScalar(volume / 100.0, None)
return True
except Exception as e:
logger.error(f"Failed to set volume: {e}")
return False
async def toggle_mute(self) -> bool:
"""Toggle mute state."""
volume_if = self._get_volume_interface()
if volume_if is None:
return False
try:
current_mute = bool(volume_if.GetMute())
volume_if.SetMute(not current_mute, None)
return not current_mute
except Exception as e:
logger.error(f"Failed to toggle mute: {e}")
return False
async def seek(self, position: float) -> bool:
"""Seek to position in seconds."""
try:
loop = asyncio.get_event_loop()
return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_seek, position),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning("Seek command timed out")
return False
except Exception as e:
logger.error(f"Failed to seek: {e}")
return False