From 2dbcd82b7dc0e25ff1365cc931dae2b0c2c9a279 Mon Sep 17 00:00:00 2001
From: Medieval Apple <114710036+MedievalApple@users.noreply.github.com>
Date: Fri, 26 Sep 2025 04:39:44 -0400
Subject: [PATCH] Add (Roku) Media Assistant provider (#2332)
---
.../roku_media_assistant/__init__.py | 65 +++
.../roku_media_assistant/constants.py | 4 +
.../providers/roku_media_assistant/icon.svg | 225 +++++++++++
.../roku_media_assistant/icon_monochrome.svg | 246 ++++++++++++
.../roku_media_assistant/manifest.json | 9 +
.../providers/roku_media_assistant/player.py | 375 ++++++++++++++++++
.../roku_media_assistant/provider.py | 175 ++++++++
requirements_all.txt | 1 +
8 files changed, 1100 insertions(+)
create mode 100644 music_assistant/providers/roku_media_assistant/__init__.py
create mode 100644 music_assistant/providers/roku_media_assistant/constants.py
create mode 100644 music_assistant/providers/roku_media_assistant/icon.svg
create mode 100644 music_assistant/providers/roku_media_assistant/icon_monochrome.svg
create mode 100644 music_assistant/providers/roku_media_assistant/manifest.json
create mode 100644 music_assistant/providers/roku_media_assistant/player.py
create mode 100644 music_assistant/providers/roku_media_assistant/provider.py
diff --git a/music_assistant/providers/roku_media_assistant/__init__.py b/music_assistant/providers/roku_media_assistant/__init__.py
new file mode 100644
index 00000000..db88dff4
--- /dev/null
+++ b/music_assistant/providers/roku_media_assistant/__init__.py
@@ -0,0 +1,65 @@
+"""Media Assistant Player Provider for Music Assistant."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import ConfigEntryType
+
+from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS
+
+from .constants import CONF_AUTO_DISCOVER, CONF_ROKU_APP_ID
+from .provider import MediaAssistantprovider
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import ConfigValueType, ProviderConfig
+ from music_assistant_models.provider import ProviderManifest
+
+ from music_assistant.mass import MusicAssistant
+ from music_assistant.models import ProviderInstanceType
+
+
+async def setup(
+ mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+ """Initialize provider(instance) with given configuration."""
+ return MediaAssistantprovider(mass, manifest, config)
+
+
+async def get_config_entries(
+ mass: MusicAssistant,
+ instance_id: str | None = None,
+ action: str | None = None,
+ values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+ """
+ Return Config entries to setup this provider.
+
+ instance_id: id of an existing provider instance (None if new instance setup).
+ action: [optional] action key called from config entries UI.
+ values: the (intermediate) raw values for config entries sent with the action.
+ """
+ # ruff: noqa: ARG001
+ return (
+ CONF_ENTRY_MANUAL_DISCOVERY_IPS,
+ ConfigEntry(
+ key=CONF_ROKU_APP_ID,
+ type=ConfigEntryType.STRING,
+ label="App ID of Media Assistant",
+ default_value="782875",
+ description="By default, Music Assistant will use the Roku Channel Store version "
+ "of Media Assistant (ID: 782875). If you sideloaded the App on your Roku "
+ "this will need to be set to (ID: dev).",
+ required=False,
+ category="advanced",
+ ),
+ ConfigEntry(
+ key=CONF_AUTO_DISCOVER,
+ type=ConfigEntryType.BOOLEAN,
+ label="Allow automatic Roku discovery",
+ default_value=True,
+ description="Enable automatic discovery of Roku players.",
+ category="advanced",
+ ),
+ )
diff --git a/music_assistant/providers/roku_media_assistant/constants.py b/music_assistant/providers/roku_media_assistant/constants.py
new file mode 100644
index 00000000..d5b3c978
--- /dev/null
+++ b/music_assistant/providers/roku_media_assistant/constants.py
@@ -0,0 +1,4 @@
+"""Constants for the Media Assistant Provider."""
+
+CONF_ROKU_APP_ID = "roku_app_id"
+CONF_AUTO_DISCOVER = "auto_scan"
diff --git a/music_assistant/providers/roku_media_assistant/icon.svg b/music_assistant/providers/roku_media_assistant/icon.svg
new file mode 100644
index 00000000..d2820c49
--- /dev/null
+++ b/music_assistant/providers/roku_media_assistant/icon.svg
@@ -0,0 +1,225 @@
+
+
+
+
diff --git a/music_assistant/providers/roku_media_assistant/icon_monochrome.svg b/music_assistant/providers/roku_media_assistant/icon_monochrome.svg
new file mode 100644
index 00000000..ddf4161f
--- /dev/null
+++ b/music_assistant/providers/roku_media_assistant/icon_monochrome.svg
@@ -0,0 +1,246 @@
+
+
+
+
diff --git a/music_assistant/providers/roku_media_assistant/manifest.json b/music_assistant/providers/roku_media_assistant/manifest.json
new file mode 100644
index 00000000..4c6950cf
--- /dev/null
+++ b/music_assistant/providers/roku_media_assistant/manifest.json
@@ -0,0 +1,9 @@
+{
+ "type": "player",
+ "domain": "roku_media_assistant",
+ "name": "Media Assistant (Roku)",
+ "description": "Support for Roku's running Media Assistant.",
+ "codeowners": ["@medievalapple"],
+ "requirements": ["async-upnp-client==0.45.0","rokuecp==0.19.5"],
+ "documentation": "https://music-assistant.io/player-support/roku-media-assistant/"
+}
diff --git a/music_assistant/providers/roku_media_assistant/player.py b/music_assistant/providers/roku_media_assistant/player.py
new file mode 100644
index 00000000..7a1c3efb
--- /dev/null
+++ b/music_assistant/providers/roku_media_assistant/player.py
@@ -0,0 +1,375 @@
+"""Media Assistant Player implementation."""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from typing import TYPE_CHECKING, Any, cast
+from urllib.parse import urlencode
+
+from music_assistant_models.enums import MediaType, PlaybackState, PlayerFeature, PlayerType
+
+from music_assistant.constants import CONF_ENTRY_HTTP_PROFILE
+from music_assistant.models.player import Player, PlayerMedia
+
+from .constants import CONF_ROKU_APP_ID
+
+if TYPE_CHECKING:
+ from music_assistant_models.config_entries import ConfigEntry
+ from rokuecp import Roku
+
+ from .provider import MediaAssistantprovider
+
+
+class MediaAssistantPlayer(Player):
+ """MediaAssistantPlayer in Music Assistant."""
+
+ def __init__(
+ self,
+ provider: MediaAssistantprovider,
+ player_id: str,
+ roku_name: str,
+ roku: Roku,
+ queued: PlayerMedia | None = None,
+ ) -> None:
+ """Initialize the Player."""
+ super().__init__(provider, player_id)
+ # init some static variables
+ self.roku = roku
+ self.queued = queued
+ self._attr_name = roku_name
+ self._attr_type = PlayerType.PLAYER
+ self._attr_supported_features = {
+ PlayerFeature.POWER, # if the player can be turned on/off
+ PlayerFeature.PAUSE,
+ PlayerFeature.VOLUME_MUTE,
+ PlayerFeature.ENQUEUE,
+ }
+ self._attr_volume_muted = False
+ self._attr_volume_level = 100
+ self.lock = asyncio.Lock() # Held when connecting or disconnecting the device
+
+ async def setup(self) -> None:
+ """Set up player in MA."""
+ self._attr_available = False
+ self._attr_powered = False
+ await self.mass.players.register_or_update(self)
+
+ @property
+ def needs_poll(self) -> bool:
+ """Return if the player needs to be polled for state updates."""
+ return True
+
+ @property
+ def poll_interval(self) -> int:
+ """Return the interval in seconds to poll the player for state updates."""
+ return 5 if self.powered else 30
+
+ async def get_config_entries(self) -> list[ConfigEntry]:
+ """Return all (provider/player specific) Config Entries for the player."""
+ default_entries = await super().get_config_entries()
+ return [
+ *default_entries,
+ CONF_ENTRY_HTTP_PROFILE,
+ ]
+
+ async def power(self, powered: bool) -> None:
+ """Handle POWER command on the player."""
+ try:
+ device_info = await self.roku.update()
+ app_running = False
+ if device_info.app is not None:
+ app_running = device_info.app.app_id == self.provider.config.get_value(
+ CONF_ROKU_APP_ID
+ )
+
+ # There's no real way to "Power" on the app since device wake up / app start
+ # is handled by The roku once it receives the Play Media request
+ if not powered:
+ self._attr_active_source = None
+ if app_running:
+ await self.roku.remote("home")
+ await self.roku.remote("power")
+
+ logger = self.provider.logger.getChild(self.player_id)
+ logger.info("Received POWER command on player %s", self.display_name)
+ # update the player state in the player manager
+ self.update_state()
+ except Exception:
+ self.logger.error("Failed to change Power state on: %s", self.name)
+
+ async def volume_mute(self, muted: bool) -> None:
+ """Handle VOLUME MUTE command on the player."""
+ await self.roku.remote("volume_mute")
+
+ logger = self.provider.logger.getChild(self.player_id)
+ logger.info(
+ "Received VOLUME_MUTE command on player %s with muted %s", self.display_name, muted
+ )
+ self._attr_volume_muted = muted
+ self.update_state()
+
+ async def play(self) -> None:
+ """Play command."""
+ await self.roku.remote("play")
+
+ logger = self.provider.logger.getChild(self.player_id)
+ logger.info("Received PLAY command on player %s", self.display_name)
+ self._attr_playback_state = PlaybackState.PLAYING
+ self.update_state()
+
+ async def stop(self) -> None:
+ """Stop command."""
+ try:
+ device_info = await self.roku.update()
+
+ app_running = False
+
+ if device_info.app is not None:
+ app_running = device_info.app.app_id == self.provider.config.get_value(
+ CONF_ROKU_APP_ID
+ )
+
+ if app_running:
+ # The closet thing the app has to playback stop,
+ # is sending a empty media object.
+ # I hope to implement a better solution into the app.
+ await self.roku_input(
+ {
+ "u": " ",
+ "t": "a",
+ "songName": "Music Assistant",
+ "artistName": "Waiting for Playback...",
+ },
+ )
+
+ logger = self.provider.logger.getChild(self.player_id)
+ logger.info("Received STOP command on player %s", self.display_name)
+ self._attr_playback_state = PlaybackState.IDLE
+ self.update_state()
+ except Exception:
+ self.logger.error("Failed to send stop signal to: %s", self.name)
+
+ async def pause(self) -> None:
+ """Pause command."""
+ await self.roku.remote("play")
+
+ logger = self.provider.logger.getChild(self.player_id)
+ logger.info("Received PAUSE command on player %s", self.display_name)
+ self._attr_playback_state = PlaybackState.PAUSED
+ self.update_state()
+
+ async def play_media(self, media: PlayerMedia) -> None:
+ """Play media command."""
+ try:
+ device_info = await self.roku.update()
+
+ app_running = False
+
+ if device_info.app is not None:
+ app_running = (
+ device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
+ if not device_info.app.screensaver
+ else False
+ )
+
+ f_media = {
+ "u": media.uri,
+ "t": "a",
+ "albumName": media.album or "",
+ "songName": media.title,
+ "artistName": (
+ "Music Assistant Radio"
+ if media.media_type == MediaType.RADIO
+ else media.artist
+ if media.artist is not None
+ else ("Flow Mode" if self.flow_mode else "Music Assistant")
+ ),
+ "albumArt": ("" if self.flow_mode else media.image_url or ""),
+ "songFormat": "flac",
+ "duration": media.duration or "",
+ "isLive": (
+ "true"
+ if media.media_type == MediaType.RADIO
+ or media.duration is None
+ or self.flow_mode
+ else ""
+ ),
+ }
+
+ if app_running:
+ await self.roku_input(f_media)
+ else:
+ await self.roku.launch(
+ cast("str", self.provider.config.get_value(CONF_ROKU_APP_ID)),
+ f_media,
+ )
+
+ logger = self.provider.logger.getChild(self.player_id)
+ logger.info(
+ "Received PLAY_MEDIA command on player %s with uri %s", self.display_name, media.uri
+ )
+ self._attr_powered = True
+ self._attr_current_media = media
+ self._attr_active_source = self.player_id
+ self.update_state()
+ except Exception:
+ self.logger.error("Failed to Play Media on: %s", self.name)
+ return
+
+ async def enqueue_next_media(self, media: PlayerMedia) -> None:
+ """Handle enqueuing of the next (queue) item on the player."""
+ try:
+ device_info = await self.roku.update()
+
+ app_running = False
+
+ if device_info.app is not None:
+ app_running = device_info.app.app_id == self.provider.config.get_value(
+ CONF_ROKU_APP_ID
+ )
+
+ if app_running:
+ await self.roku_input(
+ {
+ "u": media.uri,
+ "t": "a",
+ "albumName": media.album,
+ "songName": media.title,
+ "artistName": media.artist,
+ "albumArt": media.image_url,
+ "songFormat": "flac",
+ "duration": media.duration,
+ "enqueue": "true",
+ },
+ )
+ self.queued = media
+ except Exception:
+ self.logger.error("Failed to Enqueue Media on: %s", self.name)
+ return
+
+ async def poll(self) -> None:
+ """Poll player for state updates."""
+ # Pull Device State
+ try:
+ device_info = await self.roku.update()
+ self._attr_available = True
+ except Exception:
+ self._attr_available = False
+ self.logger.error("Failed to retrieve Update from: %s", self.name)
+ self.update_state()
+ return
+
+ app_running = False
+
+ if device_info.app is not None:
+ app_running = device_info.app.app_id == self.provider.config.get_value(CONF_ROKU_APP_ID)
+
+ # Update Device State
+ if not app_running:
+ self._attr_active_source = None
+
+ self._attr_powered = app_running
+
+ # If Media's Playing update its state
+ if self.powered and app_running:
+ try:
+ media_state = await self.roku._get_media_state()
+
+ play_states: dict[str, PlaybackState] = {
+ "play": PlaybackState.PLAYING,
+ "pause": PlaybackState.PAUSED,
+ }
+
+ self._attr_playback_state = play_states.get(
+ media_state["@state"], PlaybackState.IDLE
+ )
+
+ if "position" in media_state:
+ try:
+ self._attr_elapsed_time = (
+ int(media_state["position"].split(" ", 1)[0]) / 1000
+ )
+ self._attr_elapsed_time_last_updated = time.time()
+ except Exception:
+ self.logger.info(
+ "Playback Position received from %s Was Invalid", self.name
+ )
+
+ if self.current_media and self.current_media.source_id:
+ if not (
+ queue := self.mass.player_queues.get_active_queue(
+ self.current_media.source_id
+ )
+ ):
+ return
+ else:
+ return
+
+ if (
+ self._attr_playback_state == PlaybackState.PLAYING
+ and queue.next_item
+ and queue.current_item
+ and queue.current_item.duration
+ ):
+ if queue.elapsed_time >= queue.current_item.duration:
+ self._attr_current_media = self.queued
+
+ if (
+ self._attr_playback_state == PlaybackState.PLAYING
+ and queue.current_item
+ and queue.flow_mode
+ ):
+ current_item = queue.current_item
+
+ image_url = (
+ self.mass.metadata.get_image_url(current_item.image, size=512)
+ if current_item.image
+ else ""
+ )
+
+ album_name = ""
+ song_name = ""
+ artist_name = ""
+
+ if current_item.media_item is not None:
+ media_item = current_item.media_item
+
+ song_name = media_item.name if media_item is not None else ""
+
+ if hasattr(media_item, "album"):
+ album_name = (
+ media_item.album.name if media_item.album is not None else ""
+ )
+
+ if hasattr(media_item, "artist_str"):
+ artist_name = media_item.artist_str
+
+ if app_running:
+ await self.roku_input(
+ {
+ "u": "",
+ "t": "m",
+ "albumName": album_name,
+ "songName": song_name,
+ "artistName": artist_name,
+ "albumArt": image_url,
+ "isLive": "true",
+ },
+ )
+ except Exception:
+ self.logger.warning("Failed to update media state for: %s", self.name)
+
+ self.update_state()
+
+ async def roku_input(self, params: dict[str, Any] | None = None) -> None:
+ """Send request to the running application on the Roku device."""
+ if params is None:
+ params = {}
+
+ encoded = urlencode(params)
+ await self.roku._request(f"input?{encoded}", method="POST", encoded=True)
+
+ async def on_unload(self) -> None:
+ """Handle logic when the player is unloaded from the Player controller."""
+ self.logger.info("Player %s unloaded", self.name)
diff --git a/music_assistant/providers/roku_media_assistant/provider.py b/music_assistant/providers/roku_media_assistant/provider.py
new file mode 100644
index 00000000..ba41855f
--- /dev/null
+++ b/music_assistant/providers/roku_media_assistant/provider.py
@@ -0,0 +1,175 @@
+"""Media Assistant Provider implementation."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import TYPE_CHECKING, cast
+
+from async_upnp_client.search import async_search
+from music_assistant_models.player import DeviceInfo
+from rokuecp import Roku
+
+from music_assistant.constants import CONF_ENTRY_MANUAL_DISCOVERY_IPS, VERBOSE_LOG_LEVEL
+from music_assistant.helpers.util import TaskManager
+from music_assistant.models.player_provider import PlayerProvider
+
+from .constants import CONF_AUTO_DISCOVER
+from .player import MediaAssistantPlayer
+
+if TYPE_CHECKING:
+ from async_upnp_client.utils import CaseInsensitiveDict
+ from music_assistant_models.enums import ProviderFeature
+
+SUPPORTED_FEATURES: set[ProviderFeature] = set()
+
+
+class MediaAssistantprovider(PlayerProvider):
+ """Media Assistant Player provider."""
+
+ roku_players: dict[str, MediaAssistantPlayer] = {}
+ _discovery_running: bool = False
+ lock: asyncio.Lock
+
+ @property
+ def supported_features(self) -> set[ProviderFeature]:
+ """Return the features supported by this Provider."""
+ return SUPPORTED_FEATURES
+
+ async def handle_async_init(self) -> None:
+ """Handle async initialization of the provider."""
+ self.lock = asyncio.Lock()
+ # silence the async_upnp_client logger
+ if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
+ logging.getLogger("async_upnp_client").setLevel(logging.DEBUG)
+ else:
+ logging.getLogger("async_upnp_client").setLevel(self.logger.level + 10)
+ # silence the rokuecp logger
+ if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
+ logging.getLogger("rokuecp").setLevel(logging.DEBUG)
+ else:
+ logging.getLogger("rokuecp").setLevel(self.logger.level + 10)
+
+ async def loaded_in_mass(self) -> None:
+ """Call after the provider has been loaded."""
+ manual_ip_config = cast(
+ "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
+ )
+
+ for ip in manual_ip_config:
+ await self._device_discovered(ip)
+
+ self.logger.info("MediaAssistantProvider loaded")
+ await self.discover_players()
+
+ async def unload(self, is_removed: bool = False) -> None:
+ """Handle unload/close of the provider."""
+ if self.roku_players is None:
+ return # type: ignore[unreachable]
+ async with TaskManager(self.mass) as tg:
+ for roku_player in self.roku_players.values():
+ tg.create_task(self._device_disconnect(roku_player))
+
+ async def discover_players(self) -> None:
+ """Discover Roku players on the network."""
+ if self.config.get_value(CONF_AUTO_DISCOVER):
+ if self._discovery_running:
+ return
+ try:
+ self._discovery_running = True
+ self.logger.debug("Roku discovery started...")
+ discovered_devices: set[str] = set()
+
+ async def on_response(discovery_info: CaseInsensitiveDict) -> None:
+ """Process discovered device from ssdp search."""
+ ssdp_st: str | None = discovery_info.get("st")
+ if not ssdp_st:
+ return
+
+ if "roku:ecp" not in ssdp_st:
+ # we're only interested in Roku devices
+ return
+
+ ssdp_usn: str = discovery_info["usn"]
+ ssdp_udn: str | None = discovery_info.get("_udn")
+ if not ssdp_udn and ssdp_usn.startswith("uuid:"):
+ ssdp_udn = "ROKU_" + ssdp_usn.split(":")[-1]
+ elif ssdp_udn:
+ ssdp_udn = "ROKU_" + ssdp_udn.split(":")[-1]
+ else:
+ return
+
+ if ssdp_udn in discovered_devices:
+ # already processed this device
+ return
+
+ discovered_devices.add(ssdp_udn)
+
+ await self._device_discovered(discovery_info["_host"])
+
+ await async_search(on_response, search_target="roku:ecp")
+
+ finally:
+ self._discovery_running = False
+
+ def reschedule() -> None:
+ self.mass.create_task(self.discover_players())
+
+ # reschedule self once finished
+ self.mass.loop.call_later(300, reschedule)
+
+ async def _device_disconnect(self, roku_player: MediaAssistantPlayer) -> None:
+ """Destroy connections to the device."""
+ async with roku_player.lock:
+ if not roku_player.roku:
+ self.logger.debug("Disconnecting from device that's not connected")
+ return
+
+ self.logger.debug("Disconnecting from %s", roku_player.name)
+
+ old_device = roku_player.roku
+ self.roku_players.pop(roku_player.player_id)
+ await old_device.close_session()
+
+ async def _device_discovered(self, ip: str) -> None:
+ """Handle discovered Roku."""
+ async with self.lock:
+ # connecting to Roku to retrieve device Info
+ roku = Roku(ip)
+ try:
+ device = await roku.update()
+ await roku.close_session()
+ except Exception:
+ self.logger.error("Failed to retrieve device info from Roku at: %s", ip)
+ await roku.close_session()
+ return
+
+ if device.info.serial_number is None:
+ return
+
+ player_id = "ROKU_" + device.info.serial_number
+
+ if roku_player := self.roku_players.get(player_id):
+ # existing player
+ if roku_player.device_info.ip_address == ip and roku_player.available:
+ # nothing to do, device is already connected
+ return
+ # update description url to newly discovered one
+ roku_player.device_info.ip_address = ip
+ else:
+ roku_player = MediaAssistantPlayer(
+ provider=self,
+ player_id=player_id,
+ roku_name=device.info.name if device.info.name is not None else "",
+ roku=Roku(ip),
+ )
+
+ roku_player._attr_device_info = DeviceInfo(
+ model=device.info.model_name if device.info.model_name is not None else "",
+ model_id=device.info.model_number,
+ manufacturer=device.info.brand,
+ ip_address=ip,
+ )
+
+ self.roku_players[player_id] = roku_player
+ await roku_player.setup()
diff --git a/requirements_all.txt b/requirements_all.txt
index a60e6155..13fe85ff 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -49,6 +49,7 @@ python-fullykiosk==0.0.14
python-slugify==8.0.4
pywidevine==1.8.0
radios==0.3.2
+rokuecp==0.19.5
setuptools>=1.0.0
shortuuid==1.0.13
snapcast==2.3.7
--
2.34.1