commit 783771489a6b3bef3c077710a0ac256d9517617d Author: alexei.dolgolyov Date: Wed Feb 4 14:40:33 2026 +0300 Initial commit: HACS-ready Home Assistant integration Remote Media Player integration for controlling PC media playback from Home Assistant via the Media Server API. Features: - Full media player controls (play, pause, stop, next, previous) - Volume control and mute - Seek support with smooth timeline updates - Real-time updates via WebSocket - Script buttons for PC control (shutdown, restart, lock, etc.) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9a10d7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..461bf90 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d32bc7 --- /dev/null +++ b/README.md @@ -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 diff --git a/custom_components/remote_media_player/README.md b/custom_components/remote_media_player/README.md new file mode 100644 index 0000000..6a433a5 --- /dev/null +++ b/custom_components/remote_media_player/README.md @@ -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 diff --git a/custom_components/remote_media_player/__init__.py b/custom_components/remote_media_player/__init__.py new file mode 100644 index 0000000..f71f3bb --- /dev/null +++ b/custom_components/remote_media_player/__init__.py @@ -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) diff --git a/custom_components/remote_media_player/api_client.py b/custom_components/remote_media_player/api_client.py new file mode 100644 index 0000000..7dbcfc0 --- /dev/null +++ b/custom_components/remote_media_player/api_client.py @@ -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 diff --git a/custom_components/remote_media_player/button.py b/custom_components/remote_media_player/button.py new file mode 100644 index 0000000..8be1e76 --- /dev/null +++ b/custom_components/remote_media_player/button.py @@ -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 diff --git a/custom_components/remote_media_player/config_flow.py b/custom_components/remote_media_player/config_flow.py new file mode 100644 index 0000000..bd54f09 --- /dev/null +++ b/custom_components/remote_media_player/config_flow.py @@ -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.""" diff --git a/custom_components/remote_media_player/const.py b/custom_components/remote_media_player/const.py new file mode 100644 index 0000000..c9dafd5 --- /dev/null +++ b/custom_components/remote_media_player/const.py @@ -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" diff --git a/custom_components/remote_media_player/manifest.json b/custom_components/remote_media_player/manifest.json new file mode 100644 index 0000000..725b890 --- /dev/null +++ b/custom_components/remote_media_player/manifest.json @@ -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" +} diff --git a/custom_components/remote_media_player/media_player.py b/custom_components/remote_media_player/media_player.py new file mode 100644 index 0000000..319afb7 --- /dev/null +++ b/custom_components/remote_media_player/media_player.py @@ -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) diff --git a/custom_components/remote_media_player/services.yaml b/custom_components/remote_media_player/services.yaml new file mode 100644 index 0000000..3f22882 --- /dev/null +++ b/custom_components/remote_media_player/services.yaml @@ -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: diff --git a/custom_components/remote_media_player/strings.json b/custom_components/remote_media_player/strings.json new file mode 100644 index 0000000..1609d63 --- /dev/null +++ b/custom_components/remote_media_player/strings.json @@ -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" + } + } + } + } +} diff --git a/custom_components/remote_media_player/translations/en.json b/custom_components/remote_media_player/translations/en.json new file mode 100644 index 0000000..1609d63 --- /dev/null +++ b/custom_components/remote_media_player/translations/en.json @@ -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" + } + } + } + } +} diff --git a/custom_components/remote_media_player/translations/ru.json b/custom_components/remote_media_player/translations/ru.json new file mode 100644 index 0000000..90cbac2 --- /dev/null +++ b/custom_components/remote_media_player/translations/ru.json @@ -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": "Необязательный список аргументов для передачи скрипту" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..c5a2abf --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Remote Media Player", + "render_readme": true, + "homeassistant": "2024.1.0" +}