Refactor project into two standalone components

Split monorepo into separate units for future independent repositories:
- media-server/: Standalone FastAPI server with own README, requirements,
  config example, and CLAUDE.md
- haos-integration/: HACS-ready Home Assistant integration with hacs.json,
  own README, and CLAUDE.md

Both components now have their own .gitignore files and can be easily
extracted into separate repositories.

Also adds custom icon support for scripts configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 14:36:23 +03:00
parent 5519e449cd
commit e26df64e4b
44 changed files with 367 additions and 105 deletions

48
haos-integration/.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

View File

@@ -0,0 +1,25 @@
# HAOS Integration - Development Guide
## Overview
HACS-ready Home Assistant custom integration for controlling remote PC media playback.
## Installation
Copy `custom_components/remote_media_player/` to your Home Assistant config folder.
Or install via HACS as a custom repository.
## Requirements
Requires Media Server running on the target PC.
Media Server Repository: `TODO: Add repository URL`
## Integration Location
Integration files location: `U:\custom_components\remote_media_player`
## Git Rules
Always ask for user approval before committing changes to git.

View File

@@ -0,0 +1,98 @@
# Remote Media Player - Home Assistant Integration
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub Release](https://img.shields.io/github/v/release/YOUR_USERNAME/haos-remote-media-player)](https://github.com/YOUR_USERNAME/haos-remote-media-player/releases)
A Home Assistant custom integration 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 with smooth timeline updates
- Displays current track info (title, artist, album, artwork)
- Real-time updates via WebSocket (with HTTP polling fallback)
- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.)
- Configurable via Home Assistant UI
## Requirements
- Home Assistant 2024.1.0 or newer
- A running **Media Server** on your PC
### Media Server
This integration requires the Media Server to be running on the PC you want to control.
**Media Server Repository:** `TODO: Add repository URL`
See the Media Server documentation for installation and setup instructions.
## Installation
### HACS (Recommended)
1. Open HACS in Home Assistant
2. Click the three dots menu > **Custom repositories**
3. Add this repository URL: `https://github.com/YOUR_USERNAME/haos-remote-media-player`
4. Select category: **Integration**
5. Click **Add**
6. Search for "Remote Media Player" and click **Download**
7. Restart Home Assistant
### Manual Installation
1. Download the latest release from the [Releases](https://github.com/YOUR_USERNAME/haos-remote-media-player/releases) page
2. Extract and copy the `custom_components/remote_media_player` folder to your Home Assistant `config/custom_components/` directory
3. Restart Home Assistant
## Configuration
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 Media Server
- **Name**: Display name for this media player (optional)
## Usage
Once configured, the integration creates:
### Media Player Entity
A full-featured media player entity with:
- Play/Pause/Stop controls
- Next/Previous track
- Volume control and mute
- Seek functionality
- Current track information
### Script Button Entities
Button entities for each script defined on your Media Server:
- Lock/unlock workstation
- Shutdown, restart, sleep, hibernate
- Custom scripts
## Example Lovelace Card
```yaml
type: media-control
entity: media_player.remote_media_player
```
## Documentation
For detailed documentation, see [custom_components/remote_media_player/README.md](custom_components/remote_media_player/README.md).
## Support
- [Report an Issue](https://github.com/YOUR_USERNAME/haos-remote-media-player/issues)
- [Media Server Repository](TODO)
## 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 2024.1.0 or newer
- A running Media Server on your PC (see Media Server repository)
## 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,164 @@
"""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)
# Shutdown coordinator (WebSocket cleanup)
if "coordinator" in data:
await data["coordinator"].async_shutdown()
# Close HTTP client
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,407 @@
"""API client for communicating with the Media Server."""
from __future__ import annotations
import asyncio
import hashlib
import logging
from collections.abc import Callable
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)
class MediaServerWebSocket:
"""WebSocket client for real-time media status updates."""
def __init__(
self,
host: str,
port: int,
token: str,
on_status_update: Callable[[dict[str, Any]], None],
on_disconnect: Callable[[], None] | None = None,
) -> None:
"""Initialize the WebSocket client.
Args:
host: Server hostname or IP
port: Server port
token: API authentication token
on_status_update: Callback when status update received
on_disconnect: Callback when connection lost
"""
self._host = host
self._port = int(port)
self._token = token
self._on_status_update = on_status_update
self._on_disconnect = on_disconnect
self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}"
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._receive_task: asyncio.Task | None = None
self._running = False
async def connect(self) -> bool:
"""Establish WebSocket connection.
Returns:
True if connection successful
"""
try:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect(
self._ws_url,
heartbeat=30,
timeout=aiohttp.ClientTimeout(total=10),
)
self._running = True
# Start receive loop
self._receive_task = asyncio.create_task(self._receive_loop())
_LOGGER.info("WebSocket connected to %s:%s", self._host, self._port)
return True
except Exception as err:
_LOGGER.warning("WebSocket connection failed: %s", err)
return False
async def disconnect(self) -> None:
"""Close WebSocket connection."""
self._running = False
if self._receive_task:
self._receive_task.cancel()
try:
await self._receive_task
except asyncio.CancelledError:
pass
self._receive_task = None
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
_LOGGER.debug("WebSocket disconnected")
async def _receive_loop(self) -> None:
"""Background loop to receive WebSocket messages."""
while self._running and self._ws and not self._ws.closed:
try:
msg = await self._ws.receive(timeout=60)
if msg.type == aiohttp.WSMsgType.TEXT:
data = msg.json()
msg_type = data.get("type")
if msg_type in ("status", "status_update"):
status_data = data.get("data", {})
# Convert album art URL to absolute
if (
status_data.get("album_art_url")
and status_data["album_art_url"].startswith("/")
):
track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}"
track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8]
status_data["album_art_url"] = (
f"http://{self._host}:{self._port}"
f"{status_data['album_art_url']}?token={self._token}&t={track_hash}"
)
self._on_status_update(status_data)
elif msg_type == "pong":
_LOGGER.debug("Received pong")
elif msg.type == aiohttp.WSMsgType.CLOSED:
_LOGGER.warning("WebSocket closed by server")
break
elif msg.type == aiohttp.WSMsgType.ERROR:
_LOGGER.error("WebSocket error: %s", self._ws.exception())
break
except asyncio.TimeoutError:
# Send ping to keep connection alive
if self._ws and not self._ws.closed:
try:
await self._ws.send_json({"type": "ping"})
except Exception:
break
except asyncio.CancelledError:
break
except Exception as err:
_LOGGER.error("WebSocket receive error: %s", err)
break
# Connection lost, notify callback
if self._on_disconnect:
self._on_disconnect()
@property
def is_connected(self) -> bool:
"""Return True if WebSocket is connected."""
return self._ws is not None and not self._ws.closed

View File

@@ -0,0 +1,134 @@
"""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", ""),
script_icon=script.get("icon"),
)
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,
script_icon: str | None = None,
) -> 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
# Use custom icon if provided, otherwise auto-resolve from script name
self._attr_icon = script_icon or 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,40 @@
"""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"
CONF_USE_WEBSOCKET = "use_websocket"
# Default values
DEFAULT_PORT = 8765
DEFAULT_POLL_INTERVAL = 5
DEFAULT_NAME = "Remote Media Player"
DEFAULT_USE_WEBSOCKET = True
DEFAULT_RECONNECT_INTERVAL = 30
# 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"
API_WEBSOCKET = "/api/media/ws"
# 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-remote-media-player",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aiohttp>=3.8.0"],
"version": "1.0.0"
}

View File

@@ -0,0 +1,452 @@
"""Media player platform for Remote Media Player integration."""
from __future__ import annotations
import asyncio
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, callback
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, MediaServerWebSocket
from .const import (
DOMAIN,
CONF_HOST,
CONF_PORT,
CONF_TOKEN,
CONF_POLL_INTERVAL,
CONF_USE_WEBSOCKET,
DEFAULT_POLL_INTERVAL,
DEFAULT_NAME,
DEFAULT_USE_WEBSOCKET,
DEFAULT_RECONNECT_INTERVAL,
)
_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),
)
# Get WebSocket setting from options or data
use_websocket = entry.options.get(
CONF_USE_WEBSOCKET,
entry.data.get(CONF_USE_WEBSOCKET, DEFAULT_USE_WEBSOCKET),
)
# Create update coordinator with WebSocket support
coordinator = MediaPlayerCoordinator(
hass,
client,
poll_interval,
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
token=entry.data[CONF_TOKEN],
use_websocket=use_websocket,
)
# Set up WebSocket connection if enabled
await coordinator.async_setup()
# 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
# Store coordinator for cleanup
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
# 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 with WebSocket support."""
def __init__(
self,
hass: HomeAssistant,
client: MediaServerClient,
poll_interval: int,
host: str,
port: int,
token: str,
use_websocket: bool = True,
) -> None:
"""Initialize the coordinator.
Args:
hass: Home Assistant instance
client: Media Server API client
poll_interval: Update interval in seconds
host: Server hostname
port: Server port
token: API token
use_websocket: Whether to use WebSocket for updates
"""
super().__init__(
hass,
_LOGGER,
name="Remote Media Player",
update_interval=timedelta(seconds=poll_interval),
)
self.client = client
self._host = host
self._port = port
self._token = token
self._use_websocket = use_websocket
self._ws_client: MediaServerWebSocket | None = None
self._ws_connected = False
self._reconnect_task: asyncio.Task | None = None
self._poll_interval = poll_interval
async def async_setup(self) -> None:
"""Set up the coordinator with WebSocket if enabled."""
if self._use_websocket:
await self._connect_websocket()
async def _connect_websocket(self) -> None:
"""Establish WebSocket connection."""
if self._ws_client:
await self._ws_client.disconnect()
self._ws_client = MediaServerWebSocket(
host=self._host,
port=self._port,
token=self._token,
on_status_update=self._handle_ws_status_update,
on_disconnect=self._handle_ws_disconnect,
)
if await self._ws_client.connect():
self._ws_connected = True
# Disable polling - WebSocket handles all updates including position
self.update_interval = None
_LOGGER.info("WebSocket connected, polling disabled")
else:
self._ws_connected = False
# Keep polling as fallback
self.update_interval = timedelta(seconds=self._poll_interval)
_LOGGER.warning("WebSocket failed, falling back to polling")
# Schedule reconnect attempt
self._schedule_reconnect()
@callback
def _handle_ws_status_update(self, status_data: dict[str, Any]) -> None:
"""Handle status update from WebSocket."""
self.async_set_updated_data(status_data)
@callback
def _handle_ws_disconnect(self) -> None:
"""Handle WebSocket disconnection."""
self._ws_connected = False
# Re-enable polling as fallback
self.update_interval = timedelta(seconds=self._poll_interval)
_LOGGER.warning("WebSocket disconnected, falling back to polling")
# Schedule reconnect attempt
self._schedule_reconnect()
def _schedule_reconnect(self) -> None:
"""Schedule a WebSocket reconnection attempt."""
if self._reconnect_task and not self._reconnect_task.done():
return # Already scheduled
async def reconnect() -> None:
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
if self._use_websocket and not self._ws_connected:
_LOGGER.info("Attempting WebSocket reconnect...")
await self._connect_websocket()
self._reconnect_task = self.hass.async_create_task(reconnect())
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the API (fallback when WebSocket unavailable).
Returns:
Media status data
Raises:
UpdateFailed: On API errors
"""
try:
data = await self.client.get_status()
_LOGGER.debug("HTTP poll received status: %s", data.get("state"))
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
async def async_shutdown(self) -> None:
"""Clean up resources."""
if self._reconnect_task:
self._reconnect_task.cancel()
try:
await self._reconnect_task
except asyncio.CancelledError:
pass
if self._ws_client:
await self._ws_client.disconnect()
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": "Необязательный список аргументов для передачи скрипту"
}
}
}
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "Remote Media Player",
"render_readme": true,
"homeassistant": "2024.1.0"
}