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