From: JoProbst <95jonas.p@gmail.com> Date: Tue, 9 Sep 2025 09:33:56 +0000 (+0200) Subject: Bluesound native grouping and control of external sources (#2359) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=5d8164fbc55bd9942fac790f11daa145d4bb2398;p=music-assistant-server.git Bluesound native grouping and control of external sources (#2359) --- diff --git a/music_assistant/providers/bluesound/const.py b/music_assistant/providers/bluesound/const.py new file mode 100644 index 00000000..6d204218 --- /dev/null +++ b/music_assistant/providers/bluesound/const.py @@ -0,0 +1,114 @@ +"""Constants for the Bluesound provider.""" + +from __future__ import annotations + +from music_assistant_models.enums import PlaybackState, PlayerFeature + +from music_assistant.models.player import PlayerSource + +IDLE_POLL_INTERVAL = 30 +PLAYBACK_POLL_INTERVAL = 10 + +PLAYER_FEATURES_BASE = { + PlayerFeature.SET_MEMBERS, + PlayerFeature.VOLUME_MUTE, + PlayerFeature.PAUSE, + PlayerFeature.SELECT_SOURCE, + PlayerFeature.NEXT_PREVIOUS, + PlayerFeature.SEEK, +} + +PLAYBACK_STATE_MAP = { + "play": PlaybackState.PLAYING, + "stream": PlaybackState.PLAYING, + "stop": PlaybackState.IDLE, + "pause": PlaybackState.PAUSED, + "connecting": PlaybackState.IDLE, +} + +PLAYBACK_STATE_POLL_MAP = { + "play": PlaybackState.PLAYING, + "stream": PlaybackState.PLAYING, + "stop": PlaybackState.IDLE, + "pause": PlaybackState.PAUSED, + "connecting": "CONNECTING", +} + +SOURCE_TIDAL = "Tidal" +SOURCE_AIRPLAY = "AirPlay" +SOURCE_SPOTIFY = "Spotify" +SOURCE_RADIOPARADISE = "RadioParadise" +SOURCE_TUNEIN = "TuneIn" +SOURCE_HTTP = "http" +SOURCE_BLUETOOTH = "Bluetooth" +SOURCE_TV = "HDMI ARC" + +PLAYER_SOURCE_MAP = { + SOURCE_HTTP: PlayerSource( + id=SOURCE_HTTP, + name="HTTP Stream", + passive=True, + can_play_pause=True, + can_next_previous=False, + can_seek=False, + ), + SOURCE_BLUETOOTH: PlayerSource( + id=SOURCE_BLUETOOTH, + name="Bluetooth", + passive=True, + can_play_pause=True, + can_next_previous=False, + can_seek=False, + ), + SOURCE_TV: PlayerSource( + id=SOURCE_TV, + name="HDMI ARC", + passive=True, + can_play_pause=False, + can_next_previous=False, + can_seek=False, + ), + SOURCE_AIRPLAY: PlayerSource( + id=SOURCE_AIRPLAY, + name="AirPlay", + passive=True, + can_play_pause=True, + can_next_previous=False, + can_seek=False, + ), + SOURCE_SPOTIFY: PlayerSource( + id=SOURCE_SPOTIFY, + name="Spotify", + passive=True, + can_play_pause=True, + can_next_previous=True, + can_seek=True, + ), + SOURCE_TIDAL: PlayerSource( + id=SOURCE_TIDAL, + name="Tidal", + passive=True, + can_play_pause=True, + can_next_previous=True, + can_seek=True, + ), + SOURCE_RADIOPARADISE: PlayerSource( + id=SOURCE_RADIOPARADISE, + name="Radio Paradise", + passive=True, + can_play_pause=True, + can_next_previous=True, + can_seek=False, + ), + SOURCE_TUNEIN: PlayerSource( + id=SOURCE_TUNEIN, + name="TuneIn", + passive=True, + can_play_pause=True, + can_next_previous=False, + can_seek=False, + ), +} + +POLL_STATE_STATIC = "static" +POLL_STATE_DYNAMIC = "dynamic" diff --git a/music_assistant/providers/bluesound/manifest.json b/music_assistant/providers/bluesound/manifest.json index a8eb94ee..ab7f3349 100644 --- a/music_assistant/providers/bluesound/manifest.json +++ b/music_assistant/providers/bluesound/manifest.json @@ -5,7 +5,7 @@ "name": "Bluesound", "description": "BluOS Player provider for Music Assistant.", "codeowners": ["@cyanogenbot"], - "requirements": ["pyblu==2.0.1"], + "requirements": ["pyblu==2.0.4"], "documentation": "https://music-assistant.io/player-support/bluesound/", "mdns_discovery": ["_musc._tcp.local.","_musp._tcp.local."] } diff --git a/music_assistant/providers/bluesound/player.py b/music_assistant/providers/bluesound/player.py index b874c2fd..29596a0f 100644 --- a/music_assistant/providers/bluesound/player.py +++ b/music_assistant/providers/bluesound/player.py @@ -4,59 +4,38 @@ from __future__ import annotations import asyncio import time -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING +from music_assistant_models.config_entries import ConfigEntry from music_assistant_models.enums import PlaybackState, PlayerFeature, PlayerType from music_assistant_models.errors import PlayerCommandFailed from pyblu import Player as BluosPlayer from pyblu import Status, SyncStatus +from pyblu.entities import Input, PairedPlayer, Preset +from pyblu.errors import PlayerUnexpectedResponseError, PlayerUnreachableError from music_assistant.constants import ( CONF_ENTRY_ENABLE_ICY_METADATA, CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_ENTRY_HTTP_PROFILE_FORCED_2, CONF_ENTRY_OUTPUT_CODEC, - VERBOSE_LOG_LEVEL, ) -from music_assistant.models.player import DeviceInfo, Player, PlayerMedia +from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource +from music_assistant.providers.bluesound.const import ( + IDLE_POLL_INTERVAL, + PLAYBACK_POLL_INTERVAL, + PLAYBACK_STATE_MAP, + PLAYBACK_STATE_POLL_MAP, + PLAYER_FEATURES_BASE, + PLAYER_SOURCE_MAP, + POLL_STATE_DYNAMIC, + POLL_STATE_STATIC, +) if TYPE_CHECKING: - from music_assistant_models.config_entries import ConfigEntry - from .provider import BluesoundDiscoveryInfo, BluesoundPlayerProvider -PLAYER_FEATURES_BASE = { - PlayerFeature.SET_MEMBERS, - PlayerFeature.VOLUME_MUTE, - PlayerFeature.PAUSE, -} - -PLAYBACK_STATE_MAP = { - "play": PlaybackState.PLAYING, - "stream": PlaybackState.PLAYING, - "stop": PlaybackState.IDLE, - "pause": PlaybackState.PAUSED, - "connecting": PlaybackState.IDLE, -} - -PLAYBACK_STATE_POLL_MAP = { - "play": PlaybackState.PLAYING, - "stream": PlaybackState.PLAYING, - "stop": PlaybackState.IDLE, - "pause": PlaybackState.PAUSED, - "connecting": "CONNECTING", -} - -SOURCE_LINE_IN = "line_in" -SOURCE_AIRPLAY = "airplay" -SOURCE_SPOTIFY = "spotify" -SOURCE_UNKNOWN = "unknown" -SOURCE_RADIO = "radio" -POLL_STATE_STATIC = "static" -POLL_STATE_DYNAMIC = "dynamic" - - class BluesoundPlayer(Player): """Holds the details of the (discovered) BluOS player.""" @@ -91,13 +70,15 @@ class BluesoundPlayer(Player): ip_address=ip_address, ) self._attr_available = True + self._attr_source_list = [] self._attr_needs_poll = True - self._attr_poll_interval = 30 + self._attr_poll_interval = IDLE_POLL_INTERVAL self._attr_can_group_with = {provider.lookup_key} async def setup(self) -> None: """Set up the player.""" # Add volume support if available + await self.update_attributes() if self.discovery_info.get("zs"): self._attr_supported_features.add(PlayerFeature.VOLUME_SET) await self.mass.players.register_or_update(self) @@ -109,7 +90,9 @@ class BluesoundPlayer(Player): CONF_ENTRY_HTTP_PROFILE_FORCED_2, CONF_ENTRY_OUTPUT_CODEC, CONF_ENTRY_FLOW_MODE_ENFORCED, - CONF_ENTRY_ENABLE_ICY_METADATA, + ConfigEntry.from_dict( + {**CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), "default_value": "full"} + ), ] async def disconnect(self) -> None: @@ -125,9 +108,7 @@ class BluesoundPlayer(Player): """Send STOP command to BluOS player.""" play_state = await self.client.stop(timeout=1) if play_state == "stop": - self.poll_state = POLL_STATE_DYNAMIC - self.dynamic_poll_count = 6 - self._attr_poll_interval = 0.5 + self._set_polling_dynamic() self._attr_playback_state = PlaybackState.IDLE self.update_state() @@ -135,9 +116,7 @@ class BluesoundPlayer(Player): """Send PLAY command to BluOS player.""" play_state = await self.client.play(timeout=1) if play_state == "stream": - self.poll_state = POLL_STATE_DYNAMIC - self.dynamic_poll_count = 6 - self._attr_poll_interval = 0.5 + self._set_polling_dynamic() self._attr_playback_state = PlaybackState.PLAYING self.update_state() @@ -145,9 +124,7 @@ class BluesoundPlayer(Player): """Send PAUSE command to BluOS player.""" play_state = await self.client.pause(timeout=1) if play_state == "pause": - self.poll_state = POLL_STATE_DYNAMIC - self.dynamic_poll_count = 6 - self._attr_poll_interval = 0.5 + self._set_polling_dynamic() self.logger.debug("Set BluOS state to %s", play_state) self._attr_playback_state = PlaybackState.PAUSED self.update_state() @@ -165,16 +142,37 @@ class BluesoundPlayer(Player): self._attr_volume_muted = muted self.update_state() + async def next_track(self): + """Send NEXT TRACK command to BluOS player.""" + await self.client.skip() + self._set_polling_dynamic() + self.update_state() + + async def previous_track(self): + """Send PREVIOUS TRACK command to BluOS player.""" + await self.client.back() + self._set_polling_dynamic() + self.update_state() + + async def seek(self, position) -> None: + """Send PLAY command to BluOS player.""" + play_state = await self.client.play(seek=position, timeout=1) + if play_state in ("stream", "play"): + self._set_polling_dynamic() + self._attr_elapsed_time = position + self._attr_elapsed_time_last_updated = time.time() + self._attr_playback_state = PlaybackState.PLAYING + self.update_state() + async def play_media(self, media: PlayerMedia) -> None: """Handle PLAY MEDIA for BluOS player using the provided URL.""" self.logger.debug("Play_media called") + self.logger.debug(media) play_state = await self.client.play_url(media.uri, timeout=1) # Enable dynamic polling if play_state == "stream": - self.poll_state = POLL_STATE_DYNAMIC - self.dynamic_poll_count = 6 - self._attr_poll_interval = 0.5 + self._set_polling_dynamic() self._attr_playback_state = PlaybackState.PLAYING self.logger.debug("Set BluOS state to %s", play_state) @@ -183,6 +181,11 @@ class BluesoundPlayer(Player): if play_state in ("PlayerUnexpectedResponseError", "PlayerUnreachableError"): raise PlayerCommandFailed("Failed to start playback.") + # Optimistically update state + self._attr_current_media = media + self._attr_active_source = media.queue_id + self._attr_elapsed_time = 0 + self._attr_elapsed_time_last_updated = time.time() self.update_state() async def set_members( @@ -191,102 +194,243 @@ class BluesoundPlayer(Player): player_ids_to_remove: list[str] | None = None, ) -> None: """Handle GROUP command for BluOS player.""" - # TODO: Implement grouping logic + if not player_ids_to_add and not player_ids_to_remove: + # nothing to do + return + + def player_id_to_paired_player(player_id: str) -> PairedPlayer: + client = self.mass.players.get(player_id, raise_unavailable=True) + return PairedPlayer(client.ip_address, client.port) + + if player_ids_to_remove: + for player_id in player_ids_to_remove: + paired_player = player_id_to_paired_player(player_id) + try: + self.sync_status = await self.client.remove_follower( + paired_player.ip, paired_player.port, timeout=3 + ) + except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err: + self.logger.debug(f"Could not remove players: {err!s}") + continue + removed_player = self.mass.players.get(player_id) + if removed_player: + removed_player._set_polling_dynamic() + removed_player._attr_current_media = None + removed_player._attr_active_source = None + removed_player.update_state() + + if player_ids_to_add: + for player_id in player_ids_to_add: + paired_player = player_id_to_paired_player(player_id) + try: + await self.client.add_follower(paired_player.ip, paired_player.port, timeout=5) + except (PlayerUnexpectedResponseError, PlayerUnreachableError) as err: + self.logger.debug(f"Could not add player {paired_player}: {err!s}") + continue + self._attr_group_members.append(player_id) + added_player = self.mass.players.get(player_id) + if added_player: + added_player._set_polling_dynamic() + added_player.update_state() + + self._set_polling_dynamic() + self.update_state() async def ungroup(self) -> None: """Handle UNGROUP command for BluOS player.""" - await self.client.player.leave_group() + leader = self.client.leader + leader_player_id = self.client.provider.player_map((leader.ip, leader.port)) + await self.mass.player.get(leader_player_id).set_members(None, [self.player_id]) async def poll(self) -> None: """Poll player for state updates.""" await self.update_attributes() + def _resolve_source(self) -> None: + """Check PLAYER_SOURCE_MAP for known sources, otherwise create a new source.""" + + def resolve_analog_digital_source(source_name) -> PlayerMedia: + """Resolve Analog/Digital Source here, avoid duplicate entries in PLAYER_SOURCE_MAP.""" + return PlayerSource( + id=source_name, + name=source_name, + passive=True, + can_play_pause=False, + can_next_previous=False, + can_seek=False, + ) + + self.logger.debug(self.status) + mass_active = self.mass.streams.base_url + if self.status.stream_url and mass_active in self.status.stream_url: + self._attr_active_source = self.player_id + elif player_source := PLAYER_SOURCE_MAP.get(self.status.input_id): + self._attr_active_source = self.status.input_id + self._attr_source_list.append(player_source) + elif player_source := PLAYER_SOURCE_MAP.get(self.status.service): + self._attr_active_source = self.status.service + self._attr_source_list.append(player_source) + elif player_source := PLAYER_SOURCE_MAP.get(self.status.name): + self._attr_active_source = self.status.name + self._attr_source_list.append(player_source) + elif (name := self.status.name) and ("Analog Input" in name or "Digital Input" in name): + player_source = resolve_analog_digital_source(name) + self._attr_active_source = name + self._attr_source_list.append(player_source) + else: + self._attr_active_source = self.status.input_id + self.logger.debug("Appending new PlayerSource") + self._attr_source_list.append( + PlayerSource( + id=self.status.input_id, + name=self.status.input_id, + passive=True, + can_play_pause=True, + can_seek=self.status.can_seek, + can_next_previous=True, + ) + ) + + def _resolve_media(self) -> None: + """Resolve currently playing media dependent on available status attributes.""" + image = self.status.image + if image: + image_url = image if image.startswith("http") else self.client.base_url + image + else: + image_url = None + + self._attr_current_media = PlayerMedia( + uri=self.status.stream_url if self.status.stream_url else self.status.name, + title=self.status.name, + artist=self.status.artist, + album=self.status.album, + image_url=image_url, + duration=self.status.total_seconds if self.status.total_seconds else None, + ) + async def update_attributes(self) -> None: """Update the BluOS player attributes.""" - self.logger.debug("updating %s attributes", self.player_id) + self.logger.debug(f"updating {self.player_id} attributes") if self.dynamic_poll_count > 0: self.dynamic_poll_count -= 1 + try: + self.status = await self.client.status() + self._attr_available = True + except (PlayerUnreachableError, PlayerUnexpectedResponseError) as err: + self.logger.debug(f"Player {self.name} status check failed: {err}") + self._attr_available = False + self._attr_poll_interval = IDLE_POLL_INTERVAL + self.update_state() + return + + if ( + self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0 + ) or self._attr_playback_state == PLAYBACK_STATE_POLL_MAP[self.status.state]: + self.logger.debug(f"Changing bluos poll state from {self.poll_state} to static") + self.poll_state = POLL_STATE_STATIC + + self._attr_playback_state = PLAYBACK_STATE_MAP[self.status.state] + + # Update polling interval + if self.poll_state != POLL_STATE_DYNAMIC: + if self._attr_playback_state == PlaybackState.PLAYING: + self.logger.debug("Setting playback poll interval") + self._attr_poll_interval = PLAYBACK_POLL_INTERVAL + else: + self.logger.debug("Setting idle poll interval") + self._attr_poll_interval = IDLE_POLL_INTERVAL + self.sync_status = await self.client.sync_status() - self.status = await self.client.status() + self._attr_source_list = await self._get_bluesound_sources() + + self._attr_name = self.sync_status.name # Update timing self._attr_elapsed_time = self.status.seconds self._attr_elapsed_time_last_updated = time.time() if self.sync_status.volume == -1: + # -1 is fixed volume self._attr_volume_level = 100 else: self._attr_volume_level = self.sync_status.volume self._attr_volume_muted = self.status.mute - self.logger.log( - VERBOSE_LOG_LEVEL, - "Speaker state: %s vs reported state: %s", - PLAYBACK_STATE_POLL_MAP[self.status.state], - self._attr_playback_state, - ) - - if ( - self.poll_state == POLL_STATE_DYNAMIC and self.dynamic_poll_count <= 0 - ) or self._attr_playback_state == PLAYBACK_STATE_POLL_MAP[self.status.state]: - self.logger.debug("Changing bluos poll state from %s to static", self.poll_state) - self.poll_state = POLL_STATE_STATIC - self._attr_poll_interval = 30 - - if self.status.state == "stream": - mass_active = self.mass.streams.base_url - elif self.status.state == "stream" and self.status.input_id == "input0": - self._attr_active_source = SOURCE_LINE_IN - elif self.status.state == "stream" and self.status.input_id == "Airplay": - self._attr_active_source = SOURCE_AIRPLAY - elif self.status.state == "stream" and self.status.input_id == "Spotify": - self._attr_active_source = SOURCE_SPOTIFY - elif self.status.state == "stream" and self.status.input_id == "RadioParadise": - self._attr_active_source = SOURCE_RADIO - elif self.status.state == "stream" and (mass_active not in self.status.stream_url): - self._attr_active_source = SOURCE_UNKNOWN - - # TODO check pair status - - # TODO fix pairing - - # Create a lookup map of (ip, port) -> player_id for all known players. - player_map = { - (player.ip_address, player.port): player.player_id - for player in cast("list[BluesoundPlayer]", self.provider.players) - } - - if self.sync_status.leader is None: + if not self.sync_status.leader: + # Player not grouped or player is group leader if self.sync_status.followers: - if len(self.sync_status.followers) > 1: - self._attr_group_members = [ - player_map[f.ip, f.port] - for f in self.sync_status.followers - if (f.ip, f.port) in player_map - ] - else: - self._attr_group_members.clear() - - if self.status.state == "stream": - self._attr_current_media = PlayerMedia( - uri=self.status.stream_url, - title=self.status.name, - artist=self.status.artist, - album=self.status.album, - image_url=self.status.image, - ) + self._attr_group_members = [ + self.provider.player_map[f.ip, f.port] + for f in self.sync_status.followers + if (f.ip, f.port) in self.provider.player_map + ] else: - self._attr_current_media = None + self._attr_group_members.clear() + self._resolve_source() + self._resolve_media() else: + # Player has group leader self._attr_group_members.clear() leader = self.sync_status.leader - self._attr_active_source = player_map[leader.ip, leader.port] + leader_player_id = self.provider.player_map.get((leader.ip, leader.port), None) + self._attr_active_source = leader_player_id - self._attr_playback_state = PLAYBACK_STATE_MAP[self.status.state] self.update_state() + async def select_source(self, source: str) -> None: + """ + Handle SELECT SOURCE command on the player. + + Will only be called if the PlayerFeature.SELECT_SOURCE is supported. + + :param source: The source(id) to select, as defined in the source_list. + """ + source_type, source_id = source.split("-", 1) + if source_type == "preset": + await self.client.load_preset(preset_id=source_id) + elif source_type == "input": + await self.client.play_url(source_id) + self._set_polling_dynamic() + self.update_state() + + async def _get_bluesound_sources(self, timeout: float | None = None) -> None: + """Resolve Bluesound presets and inputs to MA PlayerSource. + + :param timeout: The timeout for getting inputs and presets. + """ + + def _preset_to_ma_source(preset: Preset): + return PlayerSource( + id=f"preset-{preset.id}", + name=f"Preset {preset.id:02d}: {preset.name}", + passive=False, + can_play_pause=True, + can_seek=False, + can_next_previous=True, + ) + + def _input_to_ma_source(bluos_input: Input): + return PlayerSource( + id=f"input-{bluos_input.url}", + name=f"Input: {bluos_input.text}", + passive=False, + can_play_pause=False, + can_seek=False, + can_next_previous=False, + ) + + presets = await self.client.presets(timeout=timeout) + inputs = await self.client.inputs(timeout=timeout) + inputs_as_sources = [_input_to_ma_source(bluos_input) for bluos_input in inputs] + return [_preset_to_ma_source(preset) for preset in presets] + inputs_as_sources + + def _set_polling_dynamic(self, poll_count: int = 6, poll_interval: float = 0.5): + self.poll_state = POLL_STATE_DYNAMIC + self.dynamic_poll_count = poll_count + self._attr_poll_interval = poll_interval + @property def synced_to(self) -> str | None: """ @@ -296,5 +440,6 @@ class BluesoundPlayer(Player): this should return None. """ if self.sync_status.leader: - return self.sync_status.leader + leader = self.sync_status.leader + return self.provider.player_map.get((leader.ip, leader.port), None) return None diff --git a/music_assistant/providers/bluesound/provider.py b/music_assistant/providers/bluesound/provider.py index 16fea47b..026b8aa5 100644 --- a/music_assistant/providers/bluesound/provider.py +++ b/music_assistant/providers/bluesound/provider.py @@ -33,12 +33,16 @@ class BluesoundDiscoveryInfo(TypedDict): class BluesoundPlayerProvider(PlayerProvider): """Bluos compatible player provider, providing support for bluesound speakers.""" - bluos_players: dict[str, BluesoundPlayer] = {} + player_map: dict[(str, str), str] = {} @property def supported_features(self) -> set[ProviderFeature]: """Return the features supported by this Provider.""" - return {ProviderFeature.SYNC_PLAYERS} + return { + ProviderFeature.SYNC_PLAYERS, + ProviderFeature.CREATE_GROUP_PLAYER, + ProviderFeature.REMOVE_GROUP_PLAYER, + } async def handle_async_init(self) -> None: """Handle async initialization of the provider.""" @@ -47,21 +51,14 @@ class BluesoundPlayerProvider(PlayerProvider): self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None ) -> None: """Handle MDNS service state callback for BluOS.""" + if state_change == ServiceStateChange.Removed: + # Wait for connection to fail, same as sonos. + return name = name.split(".", 1)[0] assert info is not None player_id = info.decoded_properties["mac"] assert player_id is not None - # Handle removed player - if state_change == ServiceStateChange.Removed: - # Check if the player manager has an existing entry for this player - if mass_player := self.mass.players.get(player_id): - # The player has become unavailable - self.logger.debug("Player offline: %s", mass_player.display_name) - mass_player._attr_available = False - mass_player.update_state() - return - ip_address = get_primary_ip_address_from_zeroconf(info) port = get_port_from_zeroconf(info) @@ -69,20 +66,17 @@ class BluesoundPlayerProvider(PlayerProvider): assert port is not None # Handle update of existing player - if bluos_player := self.bluos_players.get(player_id): - ip_changed = False + if bluos_player := self.mass.players.get(player_id): # Check if the IP address has changed if ip_address and ip_address != bluos_player.ip_address: self.logger.debug( "IP address for player %s updated to %s", bluos_player.name, ip_address ) - ip_changed = True # Always recreate the player on ip changes - - # Mark player as available if it was previously unavailable - if not bluos_player.available and not ip_changed: + else: + # IP address not changed self.logger.debug("Player back online: %s", bluos_player.name) bluos_player._attr_available = True - bluos_player.update_state() + await bluos_player.update_attributes() return # New player discovered @@ -99,7 +93,7 @@ class BluesoundPlayerProvider(PlayerProvider): # Create BluOS player bluos_player = BluesoundPlayer(self, player_id, discovery_info, name, ip_address, port) - self.bluos_players[player_id] = bluos_player + self.player_map[(ip_address, port)] = player_id # Register with Music Assistant await bluos_player.setup() diff --git a/requirements_all.txt b/requirements_all.txt index 1b714931..42581012 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -39,7 +39,7 @@ plexapi==4.17.1 podcastparser==0.6.10 propcache>=0.2.1 py-opensonic==7.0.2 -pyblu==2.0.1 +pyblu==2.0.4 PyChromecast==14.0.7 pycryptodome==3.23.0 pylast==5.5.0