From 8e8acccbb2945c9417e568bc4da79bbd2b1cc4aa Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 18 May 2026 13:13:15 +0300 Subject: [PATCH] chore: release v0.3.2 --- RELEASE_NOTES.md | 31 ++--- .../remote_media_player/README.md | 19 ++- .../remote_media_player/__init__.py | 108 ++++++++++++++++-- .../remote_media_player/api_client.py | 7 +- .../remote_media_player/manifest.json | 2 +- .../remote_media_player/services.yaml | 28 ++++- .../remote_media_player/strings.json | 18 ++- .../remote_media_player/translations/en.json | 18 ++- .../remote_media_player/translations/ru.json | 18 ++- 9 files changed, 201 insertions(+), 48 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 52f146d..95cb943 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,19 @@ -## v0.3.1 (2026-05-18) +## v0.3.2 (2026-05-18) ### Features -- **Foreground process sensors** — new "Foreground" device (linked to the hub via `via_device`) exposing what's currently in focus on the remote PC: - - `sensor.foreground_process` — process name as state, with full payload (PID, exec path, window title, fullscreen flag, monitor, geometry, browser detection, browser page title/URL, error) as attributes - - `sensor.window_title`, `sensor.pid`, `sensor.foreground_monitor`, `sensor.process_started` (TIMESTAMP device class) - - `binary_sensor.fullscreen`, `binary_sensor.minimized` - - Fed by a new `ForegroundCoordinator` polling `GET /api/foreground` every 5s, with near-real-time updates via the existing WebSocket (`foreground` / `foreground_update` push frames flow into the coordinator) ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727)) -- **Optional API token** — `CONF_TOKEN` is now optional. When the media server runs without `api_tokens` configured (auth disabled), the integration omits the `Authorization` header and `?token=` query from REST, WebSocket, and album-art URLs. Server-side auth rejections still surface as `invalid_auth` in the UI ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852)) +- **Targeted service calls** — `remote_media_player.execute_script` and `remote_media_player.play_media_file` now accept Home Assistant's standard `target:` block (`device_id`, `entity_id`, `area_id`). Calls without a target keep the legacy fan-out behavior and run on every configured hub, so existing automations continue to work. Targets are resolved against the device/entity registries and filtered to Remote Media Player hubs only; unmatched targets log a warning and are skipped. + - `services.yaml` declares `target:` with `integration: remote_media_player`, and the voluptuous schemas accept the `device_id` / `entity_id` / `area_id` keys HA injects. +- **`execute_script` parameter rename** — the script payload field is now `params:` (a named dict, validated against the server-side script schema) instead of the previous `args:` list. **Breaking** for automations that still use `args:`; update them to `params:` with the named keys your script expects. -### Performance -- **Shared `DisplayCoordinator`** — a single `/api/display/monitors` poll per cycle is now fanned out to all per-display entities (binary sensors, numbers, selects, sensors, switches) via `CoordinatorEntity`. Removes ~9x redundant requests per polling cycle that previously came from each entity calling `get_display_monitors()` in its own `async_update` ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852)) -- **Optimistic write-through** — `coordinator.apply_optimistic(...)` keeps sibling entities in sync after slider/select writes without an extra network round-trip ([ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852)) +### Bug Fixes +- **Compatibility with new browser-folders response shape** — `MediaServerClient.list_browser_folders()` now unwraps the new server response (`{"folders": {...}, "management_enabled": bool}`) introduced after server commit `c9ee41a`, while still accepting the older flat dict. Restores folder listing on freshly updated media servers. ### UI / Localization -- Display entities migrated to Home Assistant **translation keys** (`strings.json` / `translations/*`), so per-language UI text flows through the standard locale mechanism instead of hardcoded English strings ([9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727)) +- Updated English and Russian translations + `strings.json` for the new `target:`-aware service descriptions and the `params` field rename. Service descriptions now explain the "no target = all hubs" behavior. + +### Documentation +- README "Execute Script Service" section rewritten to document the `target:` block, the `params:` payload, and the destructive-script safety note. --- -
-All Commits - -| Hash | Message | Author | -|------|---------|--------| -| [9d27727](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/9d27727) | feat(foreground): foreground process sensors + translation key migration | alexei.dolgolyov | -| [ab05852](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/commit/ab05852) | feat: shared DisplayCoordinator + optional API token | alexei.dolgolyov | - -
+All changes above are bundled in the single release commit tagged [v0.3.2](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-integration-media-player/src/tag/v0.3.2). diff --git a/custom_components/remote_media_player/README.md b/custom_components/remote_media_player/README.md index 6a433a5..ef0cd50 100644 --- a/custom_components/remote_media_player/README.md +++ b/custom_components/remote_media_player/README.md @@ -207,17 +207,28 @@ automation: ### Execute Script Service -You can also execute scripts with arguments using the service: +Run a pre-defined server script. Use the `target:` block to scope the call to a +specific hub (or area / entity); omit it to fan out to **all** configured hubs. ```yaml +# Run on a single hub service: remote_media_player.execute_script +target: + device_id: data: script_name: "echo_test" - args: - - "arg1" - - "arg2" + params: + message: "hello" + +# Run on all hubs (legacy fan-out) +service: remote_media_player.execute_script +data: + script_name: "shutdown" ``` +> Without a target, the service runs on every configured Remote Media Player. +> For destructive scripts (shutdown, reboot, lock) always pin a target. + ## Lovelace Card Examples ### Basic Media Control Card diff --git a/custom_components/remote_media_player/__init__.py b/custom_components/remote_media_player/__init__.py index 8579416..e4d8a0b 100644 --- a/custom_components/remote_media_player/__init__.py +++ b/custom_components/remote_media_player/__init__.py @@ -8,9 +8,11 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er from .api_client import MediaServerClient, MediaServerError from .const import ( @@ -39,11 +41,20 @@ PLATFORMS: list[Platform] = [ Platform.SELECT, ] +# Target-selector fields injected by HA when `target:` is declared in services.yaml. +# Listed explicitly so the voluptuous schema does not strip them. +_TARGET_FIELDS = { + vol.Optional(ATTR_DEVICE_ID): vol.Any(cv.string, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): vol.Any(cv.string, [cv.string]), + vol.Optional(ATTR_AREA_ID): vol.Any(cv.string, [cv.string]), +} + # Service schema for execute_script SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema( { vol.Required(ATTR_SCRIPT_NAME): cv.string, vol.Optional(ATTR_SCRIPT_PARAMS, default={}): dict, + **_TARGET_FIELDS, } ) @@ -51,10 +62,76 @@ SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema( SERVICE_PLAY_MEDIA_FILE_SCHEMA = vol.Schema( { vol.Required(ATTR_FILE_PATH): cv.string, + **_TARGET_FIELDS, } ) +def _as_list(value: Any) -> list[str]: + """Normalize a target field to a list of IDs (HA passes str or list).""" + if value is None: + return [] + if isinstance(value, str): + return [value] + return list(value) + + +def _resolve_entry_ids(hass: HomeAssistant, call: ServiceCall) -> list[str]: + """Resolve target selectors in a ServiceCall to config entry IDs. + + Returns the entry IDs of Remote Media Player hubs that match the target. + If no target is provided, returns all configured entries (legacy fan-out). + Targets that don't resolve to any of our entries are skipped with a warning. + """ + device_ids = set(_as_list(call.data.get(ATTR_DEVICE_ID))) + entity_ids = set(_as_list(call.data.get(ATTR_ENTITY_ID))) + area_ids = set(_as_list(call.data.get(ATTR_AREA_ID))) + + domain_entries: set[str] = set(hass.data.get(DOMAIN, {}).keys()) + + if not (device_ids or entity_ids or area_ids): + return list(domain_entries) + + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + # Expand area_id -> device_ids (devices located in that area). + if area_ids: + for device in dev_reg.devices.values(): + if device.area_id in area_ids: + device_ids.add(device.id) + + matched: set[str] = set() + + for device_id in device_ids: + device = dev_reg.async_get(device_id) + if device is None: + continue + for entry_id in device.config_entries: + if entry_id in domain_entries: + matched.add(entry_id) + + for entity_id in entity_ids: + entity = ent_reg.async_get(entity_id) + if entity is None or entity.config_entry_id is None: + continue + if entity.config_entry_id in domain_entries: + matched.add(entity.config_entry_id) + + if not matched: + _LOGGER.warning( + "Service call targeted device(s)/entity(ies)/area(s) %s but no " + "Remote Media Player hubs matched — nothing will be executed", + { + "device_id": sorted(device_ids), + "entity_id": sorted(entity_ids), + "area_id": sorted(area_ids), + }, + ) + + return list(matched) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Remote Media Player from a config entry. @@ -108,17 +185,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 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.""" + """Execute a script on the targeted media server hubs.""" script_name = call.data[ATTR_SCRIPT_NAME] script_params = call.data.get(ATTR_SCRIPT_PARAMS, {}) + target_entries = _resolve_entry_ids(hass, call) _LOGGER.debug( - "Executing script '%s' with params: %s", script_name, script_params + "Executing script '%s' with params %s on entries: %s", + script_name, + script_params, + target_entries, ) - # Get all clients and execute on all of them - results = {} - for entry_id, data in hass.data[DOMAIN].items(): + results: dict[str, Any] = {} + for entry_id in target_entries: + data = hass.data[DOMAIN].get(entry_id) + if data is None: + continue client: MediaServerClient = data["client"] try: result = await client.execute_script(script_name, script_params) @@ -152,10 +235,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_play_media_file(call: ServiceCall) -> None: """Handle play_media_file service call.""" file_path = call.data[ATTR_FILE_PATH] - _LOGGER.debug("Service play_media_file called with path: %s", file_path) + target_entries = _resolve_entry_ids(hass, call) + _LOGGER.debug( + "Service play_media_file called with path '%s' on entries: %s", + file_path, + target_entries, + ) - # Execute on all configured media server instances - for entry_id, data in hass.data[DOMAIN].items(): + for entry_id in target_entries: + data = hass.data[DOMAIN].get(entry_id) + if data is None: + continue client: MediaServerClient = data["client"] try: await client.play_media_file(file_path) diff --git a/custom_components/remote_media_player/api_client.py b/custom_components/remote_media_player/api_client.py index d61062a..d2b1236 100644 --- a/custom_components/remote_media_player/api_client.py +++ b/custom_components/remote_media_player/api_client.py @@ -324,7 +324,12 @@ class MediaServerClient: Returns: Dictionary of folders with folder_id as key and folder config as value """ - return await self._request("GET", API_BROWSER_FOLDERS) + response = await self._request("GET", API_BROWSER_FOLDERS) + # Server >= c9ee41a wraps the result as {"folders": {...}, "management_enabled": bool}. + # Older servers returned the flat folder dict directly. + if isinstance(response, dict) and "folders" in response and isinstance(response["folders"], dict): + return response["folders"] + return response async def browse_folder( self, folder_id: str, path: str = "", offset: int = 0, limit: int = 100 diff --git a/custom_components/remote_media_player/manifest.json b/custom_components/remote_media_player/manifest.json index c20d952..6022bcc 100644 --- a/custom_components/remote_media_player/manifest.json +++ b/custom_components/remote_media_player/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_push", "requirements": ["aiohttp>=3.8.0"], - "version": "0.3.1" + "version": "0.3.2" } diff --git a/custom_components/remote_media_player/services.yaml b/custom_components/remote_media_player/services.yaml index fbd0dd0..dd9edf9 100644 --- a/custom_components/remote_media_player/services.yaml +++ b/custom_components/remote_media_player/services.yaml @@ -1,6 +1,13 @@ execute_script: name: Execute Script - description: Execute a pre-defined script on the media server + description: >- + Execute a pre-defined script on one or more Remote Media Player hubs. + If no target is selected, the script runs on ALL configured hubs. + target: + device: + integration: remote_media_player + entity: + integration: remote_media_player fields: script_name: name: Script Name @@ -16,3 +23,22 @@ execute_script: example: '{"level": 75, "monitor": "primary"}' selector: object: + +play_media_file: + name: Play Media File + description: >- + Start playback of a local media file on one or more Remote Media Player hubs. + If no target is selected, playback starts on ALL configured hubs. + target: + device: + integration: remote_media_player + entity: + integration: remote_media_player + fields: + file_path: + name: File Path + description: Absolute path to the media file on the target hub + required: true + example: "C:/Media/movie.mp4" + selector: + text: diff --git a/custom_components/remote_media_player/strings.json b/custom_components/remote_media_player/strings.json index 9b40c1d..58ff1de 100644 --- a/custom_components/remote_media_player/strings.json +++ b/custom_components/remote_media_player/strings.json @@ -73,15 +73,25 @@ "services": { "execute_script": { "name": "Execute Script", - "description": "Execute a pre-defined script on the media server.", + "description": "Execute a pre-defined script on one or more Remote Media Player hubs. If no target is selected, the script runs on all configured hubs.", "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" + "params": { + "name": "Parameters", + "description": "Optional named parameters to pass to the script (validated against script schema)" + } + } + }, + "play_media_file": { + "name": "Play Media File", + "description": "Start playback of a local media file on one or more Remote Media Player hubs. If no target is selected, playback starts on all configured hubs.", + "fields": { + "file_path": { + "name": "File Path", + "description": "Absolute path to the media file on the target hub" } } } diff --git a/custom_components/remote_media_player/translations/en.json b/custom_components/remote_media_player/translations/en.json index 9b40c1d..58ff1de 100644 --- a/custom_components/remote_media_player/translations/en.json +++ b/custom_components/remote_media_player/translations/en.json @@ -73,15 +73,25 @@ "services": { "execute_script": { "name": "Execute Script", - "description": "Execute a pre-defined script on the media server.", + "description": "Execute a pre-defined script on one or more Remote Media Player hubs. If no target is selected, the script runs on all configured hubs.", "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" + "params": { + "name": "Parameters", + "description": "Optional named parameters to pass to the script (validated against script schema)" + } + } + }, + "play_media_file": { + "name": "Play Media File", + "description": "Start playback of a local media file on one or more Remote Media Player hubs. If no target is selected, playback starts on all configured hubs.", + "fields": { + "file_path": { + "name": "File Path", + "description": "Absolute path to the media file on the target hub" } } } diff --git a/custom_components/remote_media_player/translations/ru.json b/custom_components/remote_media_player/translations/ru.json index f9f3d9d..5460efe 100644 --- a/custom_components/remote_media_player/translations/ru.json +++ b/custom_components/remote_media_player/translations/ru.json @@ -73,15 +73,25 @@ "services": { "execute_script": { "name": "Выполнить скрипт", - "description": "Выполнить предопределённый скрипт на медиасервере.", + "description": "Выполнить предопределённый скрипт на одном или нескольких хабах Remote Media Player. Если цель не выбрана, скрипт выполнится на всех настроенных хабах.", "fields": { "script_name": { "name": "Имя скрипта", "description": "Имя скрипта для выполнения (из конфигурации сервера)" }, - "args": { - "name": "Аргументы", - "description": "Необязательный список аргументов для передачи скрипту" + "params": { + "name": "Параметры", + "description": "Необязательные именованные параметры для скрипта (проверяются по схеме скрипта)" + } + } + }, + "play_media_file": { + "name": "Воспроизвести медиафайл", + "description": "Запустить воспроизведение локального медиафайла на одном или нескольких хабах Remote Media Player. Если цель не выбрана, воспроизведение запустится на всех настроенных хабах.", + "fields": { + "file_path": { + "name": "Путь к файлу", + "description": "Абсолютный путь к медиафайлу на целевом хабе" } } }