From 8bd7b8d845a9c1545323d2b74c4d34c8302b337c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 26 Jan 2024 00:54:27 +0100 Subject: [PATCH] Fix queue track repeat + Some improvements to Sonos players (#1029) --- music_assistant/common/models/player_queue.py | 1 - .../server/controllers/player_queues.py | 57 +-- music_assistant/server/controllers/players.py | 10 +- music_assistant/server/controllers/streams.py | 28 +- .../server/models/player_provider.py | 13 +- .../server/providers/chromecast/__init__.py | 29 +- .../server/providers/dlna/__init__.py | 40 +- .../server/providers/sonos/__init__.py | 413 +++++++++--------- 8 files changed, 280 insertions(+), 311 deletions(-) diff --git a/music_assistant/common/models/player_queue.py b/music_assistant/common/models/player_queue.py index c08b0a5e..eca83722 100644 --- a/music_assistant/common/models/player_queue.py +++ b/music_assistant/common/models/player_queue.py @@ -38,7 +38,6 @@ class PlayerQueue(DataClassDictMixin): flow_mode: bool = False # flow_mode_start_index: index of the first item of the flow stream flow_mode_start_index: int = 0 - next_track_enqueued: bool = False @property def corrected_elapsed_time(self) -> float: diff --git a/music_assistant/server/controllers/player_queues.py b/music_assistant/server/controllers/player_queues.py index 10f720bf..e0884f29 100755 --- a/music_assistant/server/controllers/player_queues.py +++ b/music_assistant/server/controllers/player_queues.py @@ -671,7 +671,7 @@ class PlayerQueuesController(CoreController): if not queue: raise PlayerUnavailableError(f"PlayerQueue {queue_id} is not available") if current_item_id_or_index is None: - cur_index = queue.index_in_buffer or queue.current_index or 0 + cur_index = queue.index_in_buffer elif isinstance(current_item_id_or_index, str): cur_index = self.index_by_id(queue_id, current_item_id_or_index) else: @@ -696,7 +696,6 @@ class PlayerQueuesController(CoreController): idx += 1 if next_item is None: raise QueueEmpty("No more (playable) tracks left in the queue.") - queue.next_track_enqueued = True return next_item # Main queue manipulation methods @@ -796,15 +795,13 @@ class PlayerQueuesController(CoreController): if not queue_items or cur_index is None: # queue is empty return None - if cur_index is None: - cur_index = queue.current_index # handle repeat single track if queue.repeat_mode == RepeatMode.ONE and not is_skip: return cur_index # handle cur_index is last index of the queue if cur_index >= (len(queue_items) - 1): # if repeat all is enabled, we simply start again from the beginning - return 0 if RepeatMode.ALL else None + return 0 if queue.repeat_mode == RepeatMode.ALL else None return cur_index + 1 def _get_next_item(self, queue_id: str, cur_index: int | None = None) -> QueueItem | None: @@ -845,33 +842,41 @@ class PlayerQueuesController(CoreController): duration = current_item.streamdetails.seconds_streamed else: duration = current_item.duration - seconds_remaining = duration - player.corrected_elapsed_time + seconds_remaining = int(duration - player.corrected_elapsed_time) + + async def _enqueue_next(index: int, supports_enqueue: bool = False): + with suppress(QueueEmpty): + next_item = await self.preload_next_item(queue.queue_id, index) + if supports_enqueue: + await self.mass.players.enqueue_next_queue_item( + player_id=player.player_id, queue_item=next_item + ) + return + await self.play_index(queue.queue_id, next_item.queue_item_id) if PlayerFeature.ENQUEUE_NEXT in player.supported_features: # player supports enqueue next feature. - # we enqueue the next track 15 seconds before the current track ends - end_of_track_reached = seconds_remaining <= 15 - else: - # player does not support enqueue next feature. - # we wait for the player to stop after it reaches the end of the track - prev_seconds_remaining = prev_state.get("seconds_remaining", seconds_remaining) - end_of_track_reached = prev_seconds_remaining <= 6 and queue.state == PlayerState.IDLE - new_state["seconds_remaining"] = seconds_remaining - - if not end_of_track_reached: - queue.next_track_enqueued = False # reset + # we enqueue the next track after a new track + # has started playing and before the current track ends + new_track_started = new_state.get("state") == PlayerState.PLAYING and prev_state.get( + "current_index" + ) != new_state.get("current_index") + if ( + new_track_started + or seconds_remaining == 15 + or int(player.corrected_elapsed_time) == 1 + ): + self.mass.create_task(_enqueue_next(queue.current_index, True)) return - if queue.next_track_enqueued: - return # already enqueued - async def _enqueue_next(index: int): - with suppress(QueueEmpty): - next_item = await self.preload_next_item(queue.queue_id, index) - await self.mass.players.enqueue_next_queue_item( - player_id=player.player_id, queue_item=next_item - ) + # player does not support enqueue next feature. + # we wait for the player to stop after it reaches the end of the track + prev_seconds_remaining = prev_state.get("seconds_remaining", seconds_remaining) + if prev_seconds_remaining <= 6 and queue.state == PlayerState.IDLE: + self.mass.create_task(_enqueue_next(queue.current_index, False)) + return - self.mass.create_task(_enqueue_next(queue.current_index)) + new_state["seconds_remaining"] = seconds_remaining async def _get_radio_tracks(self, queue_id: str) -> list[MediaItemType]: """Call the registered music providers for dynamic tracks.""" diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index cced5ca3..607cf6b4 100755 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -608,10 +608,9 @@ class PlayerController(CoreController): """ Handle enqueuing of the next queue item on the player. - If the player supports PlayerFeature.ENQUE_NEXT: - This will be called about 10 seconds before the end of the track. - If the player does NOT report support for PlayerFeature.ENQUE_NEXT: - This will be called when the end of the track is reached. + Only called if the player supports PlayerFeature.ENQUE_NEXT. + Called about 1 second after a new track started playing. + Called about 15 seconds before the end of the current track. A PlayerProvider implementation is in itself responsible for handling this so that the queue items keep playing until its empty or the player stopped. @@ -944,7 +943,6 @@ class PlayerController(CoreController): # extract player features from first/random player for member in members: if first_player := self.get(member): - supported_features = first_player.supported_features break else: # edge case: no child player is (yet) available; postpone register @@ -957,7 +955,7 @@ class PlayerController(CoreController): available=True, powered=False, device_info=DeviceInfo(model="SyncGroup", manufacturer=provider.title()), - supported_features=supported_features, + supported_features=first_player.supported_features, group_childs=set(members), ) self.mass.players.register_or_update(player) diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 67ddd7ce..f75b6c82 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -397,27 +397,7 @@ class StreamsController(CoreController): fmt = output_codec.value # handle raw pcm if output_codec.is_pcm(): - player = self.mass.players.get(queue_item.queue_id) - player_max_bit_depth = 24 if player.supports_24bit else 16 - if flow_mode: - output_sample_rate = min(FLOW_MAX_SAMPLE_RATE, player.max_sample_rate) - output_bit_depth = min(FLOW_MAX_BIT_DEPTH, player_max_bit_depth) - else: - await set_stream_details(self.mass, queue_item) - output_sample_rate = min( - queue_item.streamdetails.audio_format.sample_rate, player.max_sample_rate - ) - output_bit_depth = min( - queue_item.streamdetails.audio_format.bit_depth, player_max_bit_depth - ) - output_channels = self.mass.config.get_raw_player_config_value( - queue_item.queue_id, CONF_OUTPUT_CHANNELS, "stereo" - ) - channels = 1 if output_channels != "stereo" else 2 - fmt += ( - f";codec=pcm;rate={output_sample_rate};" - f"bitrate={output_bit_depth};channels={channels}" - ) + raise RuntimeError("PCM is not possible as output format") query_params = {} base_path = "flow" if flow_mode else "single" url = f"{self._server.base_url}/{queue_item.queue_id}/{base_path}/{queue_item.queue_item_id}.{fmt}" # noqa: E501 @@ -515,7 +495,7 @@ class StreamsController(CoreController): self.logger.debug( "Start serving audio stream for QueueItem %s to %s", queue_item.uri, queue.display_name ) - queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_item_id) + queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_id, queue_item_id) # collect player specific ffmpeg args to re-encode the source PCM stream pcm_format = AudioFormat( content_type=ContentType.from_bit_depth( @@ -821,7 +801,9 @@ class StreamsController(CoreController): queue_track.name, queue.display_name, ) - queue.index_in_buffer = self.mass.player_queues.index_by_id(queue_track.queue_item_id) + queue.index_in_buffer = self.mass.player_queues.index_by_id( + queue.queue_id, queue_track.queue_item_id + ) # set some basic vars pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2) diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index 85f03c53..0d06a2af 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -132,10 +132,9 @@ class PlayerProvider(Provider): """ Handle enqueuing of the next queue item on the player. - If the player supports PlayerFeature.ENQUE_NEXT: - This will be called about 10 seconds before the end of the track. - If the player does NOT report support for PlayerFeature.ENQUE_NEXT: - This will be called when the end of the track is reached. + Only called if the player supports PlayerFeature.ENQUE_NEXT. + Called about 1 second after a new track started playing. + Called about 15 seconds before the end of the current track. A PlayerProvider implementation is in itself responsible for handling this so that the queue items keep playing until its empty or the player stopped. @@ -143,12 +142,6 @@ class PlayerProvider(Provider): This will NOT be called if the end of the queue is reached (and repeat disabled). This will NOT be called if the player is using flow mode to playback the queue. """ - # default implementation (for a player without enqueue_next feature): - # simply start playback of the next track. - # player providers need to override this behavior if/when needed - await self.play_media( - player_id=player_id, queue_item=queue_item, seek_position=0, fade_in=False - ) async def cmd_power(self, player_id: str, powered: bool) -> None: """Send POWER command to given player. diff --git a/music_assistant/server/providers/chromecast/__init__.py b/music_assistant/server/providers/chromecast/__init__.py index 013767a0..2fd3273f 100644 --- a/music_assistant/server/providers/chromecast/__init__.py +++ b/music_assistant/server/providers/chromecast/__init__.py @@ -250,19 +250,17 @@ class ChromecastProvider(PlayerProvider): # This comes at the cost of metadata (cast does not support ICY metadata). cc_queue_items = [ self._create_cc_queue_item(None, url), + # add a special 'command' item to the queue + # this allows for on-player next buttons/commands to still work + self._create_cc_queue_item( + None, self.mass.streams.get_command_url(queue_item.queue_id, "next") + ), ] else: # handle normal playback using the chromecast queue to play items one by one cc_queue_items = [ self._create_cc_queue_item(queue_item, url), ] - # add a special 'command' item to the queue - # this allows for on-player next buttons/commands to still work - cc_queue_items.append( - self._create_cc_queue_item( - None, self.mass.streams.get_command_url(queue_item.queue_id, "next") - ) - ) queuedata = { "type": "QUEUE_LOAD", "repeatMode": "REPEAT_OFF", # handled by our queue controller @@ -299,8 +297,16 @@ class ChromecastProvider(PlayerProvider): queue_item=queue_item, output_codec=ContentType.FLAC, ) - if cast_queue_items := getattr(castplayer.cc.media_controller.status, "items"): + next_item_id = None + if (cast_queue_items := getattr(castplayer.cc.media_controller.status, "items")) and len( + cast_queue_items + ) > 1: next_item_id = cast_queue_items[-1]["itemId"] + if ( + cast_queue_items[-1].get("media", {}).get("customData", {}).get("queue_item_id") + == queue_item.queue_item_id + ): + return queuedata = { "type": "QUEUE_INSERT", "insertBefore": next_item_id, @@ -308,7 +314,12 @@ class ChromecastProvider(PlayerProvider): } media_controller = castplayer.cc.media_controller queuedata["mediaSessionId"] = media_controller.status.media_session_id - await asyncio.to_thread(media_controller.send_message, queuedata, True) + self.mass.create_task(media_controller.send_message, queuedata, inc_session_id=True) + self.logger.info( + "Enqued next track (%s) to player %s", + queue_item.name if queue_item else url, + castplayer.player.display_name, + ) async def poll_player(self, player_id: str) -> None: """Poll player for state updates. diff --git a/music_assistant/server/providers/dlna/__init__.py b/music_assistant/server/providers/dlna/__init__.py index e9d0845a..f973dc87 100644 --- a/music_assistant/server/providers/dlna/__init__.py +++ b/music_assistant/server/providers/dlna/__init__.py @@ -410,20 +410,7 @@ class DLNAPlayerProvider(PlayerProvider): @catch_request_errors async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): - """ - Handle enqueuing of the next queue item on the player. - - If the player supports PlayerFeature.ENQUE_NEXT: - This will be called about 10 seconds before the end of the track. - If the player does NOT report support for PlayerFeature.ENQUE_NEXT: - This will be called when the end of the track is reached. - - A PlayerProvider implementation is in itself responsible for handling this - so that the queue items keep playing until its empty or the player stopped. - - This will NOT be called if the end of the queue is reached (and repeat disabled). - This will NOT be called if flow mode is enabled on the queue. - """ + """Handle enqueuing of the next queue item on the player.""" dlna_player = self.dlnaplayers[player_id] url = await self.mass.streams.resolve_stream_url( queue_item=queue_item, @@ -431,25 +418,12 @@ class DLNAPlayerProvider(PlayerProvider): ) didl_metadata = create_didl_metadata(self.mass, url, queue_item) title = queue_item.name - if self.mass.config.get_raw_player_config_value(player_id, CONF_ENQUEUE_NEXT, False): - # use the 'next_transport_uri' feature - await dlna_player.device.async_set_next_transport_uri(url, title, didl_metadata) - self.logger.debug( - "Enqued next track (%s) to player %s", - title, - dlna_player.player.display_name, - ) - else: - # simply use regular play command - await dlna_player.device.async_set_transport_uri(url, title, didl_metadata) - # Play it - await dlna_player.device.async_wait_for_can_play(10) - # optimistically set this timestamp to help in case of a player - # that does not report the progress - now = time.time() - dlna_player.player.elapsed_time = 0 - dlna_player.player.elapsed_time_last_updated = now - await dlna_player.device.async_play() + await dlna_player.device.async_set_next_transport_uri(url, title, didl_metadata) + self.logger.info( + "Enqued next track (%s) to player %s", + title, + dlna_player.player.display_name, + ) @catch_request_errors async def cmd_pause(self, player_id: str) -> None: diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 1b9e3fd4..9248f0db 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -5,7 +5,6 @@ import asyncio import logging import time from contextlib import suppress -from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any import soco @@ -41,6 +40,7 @@ if TYPE_CHECKING: from music_assistant.server.controllers.streams import MultiClientStreamJob from music_assistant.server.models import ProviderInstanceType +LOGGER = logging.getLogger(__name__) PLAYER_FEATURES = ( PlayerFeature.SYNC, @@ -66,6 +66,8 @@ HIRES_MODELS = ( "Sonos Amp", "SYMFONISK Bookshelf", "SYMFONISK Table Lamp", + "Sonos Era 100", + "Sonos Era 300", ) @@ -104,31 +106,29 @@ async def get_config_entries( ) -@dataclass class SonosPlayer: """Wrapper around Sonos/SoCo with some additional attributes.""" - player_id: str - soco: soco.SoCo - player: Player - is_stereo_pair: bool = False - elapsed_time: int = 0 - playback_started: float | None = None - need_elapsed_time_workaround: bool = False - - subscriptions: list[SubscriptionBase] = field(default_factory=list) - - transport_info: dict = field(default_factory=dict) - track_info: dict = field(default_factory=dict) - speaker_info: dict = field(default_factory=dict) - rendering_control_info: dict = field(default_factory=dict) - group_info: ZoneGroup | None = None - - speaker_info_updated: float = 0.0 - transport_info_updated: float = 0.0 - track_info_updated: float = 0.0 - rendering_control_info_updated: float = 0.0 - group_info_updated: float = 0.0 + def __init__(self, sonos_prov: SonosPlayerProvider, soco_device: soco.SoCo) -> None: + """Initialize SonosPlayer instance.""" + self.sonos_prov = sonos_prov + self.player_id = soco_device.uid + self.soco_device = soco_device + self.is_stereo_pair: bool = False + self.elapsed_time: int = 0 + self.playback_started: float | None = None + self.need_elapsed_time_workaround: bool = False + self.subscriptions: list[SubscriptionBase] = [] + self.transport_info: dict = {} + self.track_info: dict = {} + self.speaker_info: dict = {} + self.rendering_control_info: dict = {} + self.group_info: ZoneGroup | None = None + self.speaker_info_updated: float = 0.0 + self.transport_info_updated: float = 0.0 + self.track_info_updated: float = 0.0 + self.rendering_control_info_updated: float = 0.0 + self.group_info_updated: float = 0.0 def update_info( self, @@ -141,13 +141,13 @@ class SonosPlayer: """Poll all info from player (must be run in executor thread).""" # transport info if update_transport_info: - transport_info = self.soco.get_current_transport_info() + transport_info = self.soco_device.get_current_transport_info() if transport_info.get("current_transport_state") != "TRANSITIONING": self.transport_info = transport_info self.transport_info_updated = time.time() # track info if update_track_info: - self.track_info = self.soco.get_current_track_info() + self.track_info = self.soco_device.get_current_track_info() # sonos reports bullshit elapsed time while playing radio (or flow mode), # trying to be "smart" and resetting the counter when new ICY metadata is detected # we try to detect this and work around it @@ -158,30 +158,33 @@ class SonosPlayer: # speaker info if update_speaker_info: - self.speaker_info = self.soco.get_speaker_info() + self.speaker_info = self.soco_device.get_speaker_info() self.speaker_info_updated = time.time() # rendering control info if update_rendering_control_info: - self.rendering_control_info["volume"] = self.soco.volume - self.rendering_control_info["mute"] = self.soco.mute + self.rendering_control_info["volume"] = self.soco_device.volume + self.rendering_control_info["mute"] = self.soco_device.mute self.rendering_control_info_updated = time.time() # group info if update_group_info: - self.group_info = self.soco.group + self.group_info = self.soco_device.group self.group_info_updated = time.time() def update_attributes(self): """Update attributes of the MA Player from soco.SoCo state.""" + mass_player = self.sonos_prov.mass.players.get(self.player_id) + if not mass_player: + return now = time.time() # generic attributes (speaker_info) - self.player.available = True - self.player.name = self.speaker_info["zone_name"] - self.player.volume_level = int(self.rendering_control_info["volume"]) - self.player.volume_muted = self.rendering_control_info["mute"] + mass_player.available = True + mass_player.name = self.speaker_info["zone_name"] + mass_player.volume_level = int(self.rendering_control_info["volume"]) + mass_player.volume_muted = self.rendering_control_info["mute"] # transport info (playback state) current_transport_state = self.transport_info["current_transport_state"] - self.player.state = current_state = _convert_state(current_transport_state) + mass_player.state = current_state = _convert_state(current_transport_state) if self.playback_started is not None and current_state == PlayerState.IDLE: self.playback_started = None @@ -189,19 +192,18 @@ class SonosPlayer: self.playback_started = now # media info (track info) - self.player.current_item_id = self.track_info["uri"] - if self.player.player_id in self.player.current_item_id: - self.player.active_source = self.player.player_id - elif "spotify" in self.player.current_item_id: - self.player.active_source = "spotify" - elif self.player.current_item_id.startswith("http"): - self.player.active_source = "http" + mass_player.current_item_id = self.track_info["uri"] + if mass_player.player_id in mass_player.current_item_id: + mass_player.active_source = mass_player.player_id + elif "spotify" in mass_player.current_item_id: + mass_player.active_source = "spotify" else: - # TODO: handle other possible sources here - self.player.active_source = None + mass_player.active_source = self.soco_device.music_source_from_uri( + self.track_info["uri"] + ) if not self.need_elapsed_time_workaround: - self.player.elapsed_time = self.elapsed_time - self.player.elapsed_time_last_updated = self.track_info_updated + mass_player.elapsed_time = self.elapsed_time + mass_player.elapsed_time_last_updated = self.track_info_updated # zone topology (syncing/grouping) details if ( @@ -210,22 +212,22 @@ class SonosPlayer: and self.group_info.coordinator.uid == self.player_id ): # this player is the sync leader - self.player.synced_to = None + mass_player.synced_to = None group_members = {x.uid for x in self.group_info.members if x.is_visible} if not group_members: # not sure about this ?! - self.player.type = PlayerType.PLAYER + mass_player.type = PlayerType.PLAYER elif group_members == {self.player_id}: - self.player.group_childs = set() + mass_player.group_childs = set() else: - self.player.group_childs = group_members + mass_player.group_childs = group_members elif self.group_info and self.group_info.coordinator: # player is synced to - self.player.group_childs = set() - self.player.synced_to = self.group_info.coordinator.uid + mass_player.group_childs = set() + mass_player.synced_to = self.group_info.coordinator.uid else: # unsure - self.player.group_childs = set() + mass_player.group_childs = set() async def check_poll(self) -> None: """Check if any of the endpoints needs to be polled for info.""" @@ -256,6 +258,85 @@ class SonosPlayer: update_group_info, ) + async def connect(self) -> None: + """Handle (re)connect of the Sonos player.""" + # poll all endpoints once and update attributes + self.speaker_info = await asyncio.to_thread(self.soco_device.get_speaker_info, True) + self.speaker_info_updated = time.time() + await self.check_poll() + self.update_attributes() + + # handle subscriptions to events + def subscribe(service, _callback): + queue = ProcessSonosEventQueue(_callback) + sub = service.subscribe(auto_renew=True, event_queue=queue) + self.subscriptions.append(sub) + + subscribe(self.soco_device.avTransport, self._handle_av_transport_event) + subscribe(self.soco_device.renderingControl, self._handle_rendering_control_event) + subscribe(self.soco_device.zoneGroupTopology, self._handle_zone_group_topology_event) + + def disconnect(self) -> None: + """Handle disconnect.""" + mass_player = self.sonos_prov.mass.players.get(self.player_id) + mass_player.available = False + LOGGER.debug("Unsubscribing from events for %s", mass_player.display_name) + for subscription in self.subscriptions: + subscription.unsubscribe() + self.subscriptions = [] + + async def reconnect(self, soco_device: soco.SoCo) -> None: + """Handle reconnect.""" + if self.subscriptions: + self.disconnect() + self.soco_device = soco_device + await self.connect() + + def _handle_av_transport_event(self, event: SonosEvent): + """Handle a soco.SoCo AVTransport event.""" + LOGGER.debug("Received AVTransport event for Player %s", self.soco_device.player_name) + + if "transport_state" in event.variables: + new_state = event.variables["transport_state"] + if new_state == "TRANSITIONING": + return + self.transport_info["current_transport_state"] = new_state + + if "current_track_uri" in event.variables: + self.transport_info["uri"] = event.variables["current_track_uri"] + + self.transport_info_updated = time.time() + asyncio.run_coroutine_threadsafe( + self.sonos_prov.update_player(self), self.sonos_prov.mass.loop + ) + + def _handle_rendering_control_event(self, event: SonosEvent): + """Handle a soco.SoCo RenderingControl event.""" + LOGGER.debug( + "Received RenderingControl event for Player %s", + self.soco_device.player_name, + ) + if "volume" in event.variables: + self.rendering_control_info["volume"] = event.variables["volume"]["Master"] + if "mute" in event.variables: + self.rendering_control_info["mute"] = event.variables["mute"]["Master"] == "1" + self.rendering_control_info_updated = time.time() + asyncio.run_coroutine_threadsafe( + self.sonos_prov.update_player(self), self.sonos_prov.mass.loop + ) + + def _handle_zone_group_topology_event(self, event: SonosEvent): # noqa: ARG002 + """Handle a soco.SoCo ZoneGroupTopology event.""" + LOGGER.debug( + "Received ZoneGroupTopology event for Player %s", + self.soco_device.player_name, + ) + self.group_info = self.soco_device.group + self.group_info_updated = time.time() + asyncio.run_coroutine_threadsafe( + self.sonos_prov.update_player(self), self.sonos_prov.mass.loop + ) + class SonosPlayerProvider(PlayerProvider): """Sonos Player provider.""" @@ -290,12 +371,7 @@ class SonosPlayerProvider(PlayerProvider): if self.sonosplayers: for player_id in list(self.sonosplayers): player = self.sonosplayers.pop(player_id) - player.player.available = False - if player.soco.is_coordinator: - try: - player.soco.end_direct_control_session() - except Exception as err: - self.logger.exception(err) + player.disconnect() self.sonosplayers = None async def get_player_config_entries( @@ -314,7 +390,7 @@ class SonosPlayerProvider(PlayerProvider): default_value=0, range=(-10, 10), description="Set the Bass level for the Sonos player", - value=sonos_player.soco.bass, + value=sonos_player.soco_device.bass, advanced=True, ), ConfigEntry( @@ -324,7 +400,7 @@ class SonosPlayerProvider(PlayerProvider): default_value=0, range=(-10, 10), description="Set the Treble level for the Sonos player", - value=sonos_player.soco.treble, + value=sonos_player.soco_device.treble, advanced=True, ), ConfigEntry( @@ -333,7 +409,7 @@ class SonosPlayerProvider(PlayerProvider): label="Loudness compensation", default_value=True, description="Enable loudness compensation on the Sonos player", - value=sonos_player.soco.loudness, + value=sonos_player.soco_device.loudness, advanced=True, ), ) @@ -350,18 +426,18 @@ class SonosPlayerProvider(PlayerProvider): return if "values/sonos_bass" in changed_keys: self.mass.create_task( - sonos_player.soco.renderingControl.SetBass, + sonos_player.soco_device.renderingControl.SetBass, [("InstanceID", 0), ("DesiredBass", config.get_value("sonos_bass"))], ) if "values/sonos_treble" in changed_keys: self.mass.create_task( - sonos_player.soco.renderingControl.SetTreble, + sonos_player.soco_device.renderingControl.SetTreble, [("InstanceID", 0), ("DesiredTreble", config.get_value("sonos_treble"))], ) if "values/sonos_loudness" in changed_keys: loudness_value = "1" if config.get_value("sonos_loudness") else "0" self.mass.create_task( - sonos_player.soco.renderingControl.SetLoudness, + sonos_player.soco_device.renderingControl.SetLoudness, [ ("InstanceID", 0), ("Channel", "Master"), @@ -372,31 +448,31 @@ class SonosPlayerProvider(PlayerProvider): async def cmd_stop(self, player_id: str) -> None: """Send STOP command to given player.""" sonos_player = self.sonosplayers[player_id] - if not sonos_player.soco.is_coordinator: + if not sonos_player.soco_device.is_coordinator: self.logger.debug( "Ignore STOP command for %s: Player is synced to another player.", player_id, ) return - await asyncio.to_thread(sonos_player.soco.stop) - await asyncio.to_thread(sonos_player.soco.clear_queue) + await asyncio.to_thread(sonos_player.soco_device.stop) + await asyncio.to_thread(sonos_player.soco_device.clear_queue) sonos_player.playback_started = None async def cmd_play(self, player_id: str) -> None: """Send PLAY command to given player.""" sonos_player = self.sonosplayers[player_id] - if not sonos_player.soco.is_coordinator: + if not sonos_player.soco_device.is_coordinator: self.logger.debug( "Ignore PLAY command for %s: Player is synced to another player.", player_id, ) return - await asyncio.to_thread(sonos_player.soco.play) + await asyncio.to_thread(sonos_player.soco_device.play) async def cmd_pause(self, player_id: str) -> None: """Send PAUSE command to given player.""" sonos_player = self.sonosplayers[player_id] - if not sonos_player.soco.is_coordinator: + if not sonos_player.soco_device.is_coordinator: self.logger.debug( "Ignore PLAY command for %s: Player is synced to another player.", player_id, @@ -406,14 +482,14 @@ class SonosPlayerProvider(PlayerProvider): # no pause allowed when radio/flow mode is active await self.cmd_stop(player_id) return - await asyncio.to_thread(sonos_player.soco.pause) + await asyncio.to_thread(sonos_player.soco_device.pause) async def cmd_volume_set(self, player_id: str, volume_level: int) -> None: """Send VOLUME_SET command to given player.""" def set_volume_level(player_id: str, volume_level: int) -> None: sonos_player = self.sonosplayers[player_id] - sonos_player.soco.volume = volume_level + sonos_player.soco_device.volume = volume_level await asyncio.to_thread(set_volume_level, player_id, volume_level) @@ -422,7 +498,7 @@ class SonosPlayerProvider(PlayerProvider): def set_volume_mute(player_id: str, muted: bool) -> None: sonos_player = self.sonosplayers[player_id] - sonos_player.soco.mute = muted + sonos_player.soco_device.mute = muted await asyncio.to_thread(set_volume_mute, player_id, muted) @@ -439,7 +515,7 @@ class SonosPlayerProvider(PlayerProvider): while True: try: await asyncio.to_thread( - sonos_player.soco.join, self.sonosplayers[target_player].soco + sonos_player.soco_device.join, self.sonosplayers[target_player].soco ) break except soco.exceptions.SoCoUPnPException as err: @@ -460,7 +536,7 @@ class SonosPlayerProvider(PlayerProvider): - player_id: player_id of the player to handle the command. """ sonos_player = self.sonosplayers[player_id] - await asyncio.to_thread(sonos_player.soco.unjoin) + await asyncio.to_thread(sonos_player.soco_device.unjoin) await asyncio.to_thread( sonos_player.update_info, update_group_info=True, @@ -491,22 +567,20 @@ class SonosPlayerProvider(PlayerProvider): flow_mode=False, ) sonos_player = self.sonosplayers[player_id] - if not sonos_player.soco.is_coordinator: + mass_player = self.mass.players.get(player_id) + if not sonos_player.soco_device.is_coordinator: # this should be already handled by the player manager, but just in case... raise PlayerCommandFailed( - f"Player {sonos_player.player.display_name} can not " + f"Player {mass_player.display_name} can not " "accept play_media command, it is synced to another player." ) - # always stop and clear queue first - await asyncio.to_thread(sonos_player.soco.stop) - await asyncio.to_thread(sonos_player.soco.clear_queue) - await self._enqueue_item(sonos_player, url=url, queue_item=queue_item) - await asyncio.to_thread(sonos_player.soco.play_from_queue, 0) + metadata = create_didl_metadata(self.mass, url, queue_item) + await asyncio.to_thread(sonos_player.soco_device.play_uri, url, meta=metadata) # optimistically set this timestamp to help figure out elapsed time later now = time.time() sonos_player.playback_started = now - sonos_player.player.elapsed_time = 0 - sonos_player.player.elapsed_time_last_updated = now + mass_player.elapsed_time = 0 + mass_player.elapsed_time_last_updated = now async def play_stream(self, player_id: str, stream_job: MultiClientStreamJob) -> None: """Handle PLAY STREAM on given player. @@ -515,21 +589,25 @@ class SonosPlayerProvider(PlayerProvider): """ url = stream_job.resolve_stream_url(player_id, ContentType.MP3) sonos_player = self.sonosplayers[player_id] - if not sonos_player.soco.is_coordinator: + mass_player = self.mass.players.get(player_id) + if not sonos_player.soco_device.is_coordinator: # this should be already handled by the player manager, but just in case... raise PlayerCommandFailed( - f"Player {sonos_player.player.display_name} can not " + f"Player {mass_player.display_name} can not " "accept play_stream command, it is synced to another player." ) - # always stop and clear queue first - await asyncio.to_thread(sonos_player.soco.stop) - await asyncio.to_thread(sonos_player.soco.clear_queue) - await asyncio.to_thread(sonos_player.soco.play_uri, url, force_radio=True) + metadata = create_didl_metadata(self.mass, url, None) + await asyncio.to_thread(sonos_player.soco_device.play_uri, url, meta=metadata) + # add a special 'command' item to the sonos queue + # this allows for on-player next buttons/commands to still work + await self._enqueue_item( + sonos_player, self.mass.streams.get_command_url(player_id, "next"), None + ) # optimistically set this timestamp to help figure out elapsed time later now = time.time() sonos_player.playback_started = now - sonos_player.player.elapsed_time = 0 - sonos_player.player.elapsed_time_last_updated = now + mass_player.elapsed_time = 0 + mass_player.elapsed_time_last_updated = now async def enqueue_next_queue_item(self, player_id: str, queue_item: QueueItem): """ @@ -553,11 +631,11 @@ class SonosPlayerProvider(PlayerProvider): ) # set crossfade according to player setting crossfade = await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE) - if sonos_player.soco.cross_fade != crossfade: + if sonos_player.soco_device.cross_fade != crossfade: def set_crossfade(): with suppress(Exception): - sonos_player.soco.cross_fade = crossfade + sonos_player.soco_device.cross_fade = crossfade await asyncio.to_thread(set_crossfade) @@ -586,7 +664,7 @@ class SonosPlayerProvider(PlayerProvider): # based on when we last received info from the device await sonos_player.check_poll() # always update the attributes - await self._update_player(sonos_player, signal_update=False) + await self.update_player(sonos_player, signal_update=False) except ConnectionResetError as err: raise PlayerUnavailableError from err @@ -602,13 +680,10 @@ class SonosPlayerProvider(PlayerProvider): ) if discovered_devices is None: discovered_devices = set() - new_device_ids = {item.uid for item in discovered_devices} - cur_player_ids = set(self.sonosplayers.keys()) - added_devices = new_device_ids.difference(cur_player_ids) # process new players for device in discovered_devices: - if device.uid not in added_devices: + if (existing := self.mass.players.get(device.uid)) and existing.available: continue try: await self._device_discovered(device) @@ -628,22 +703,22 @@ class SonosPlayerProvider(PlayerProvider): async def _device_discovered(self, soco_device: soco.SoCo) -> None: """Handle discovered Sonos player.""" player_id = soco_device.uid - enabled = self.mass.config.get(f"{CONF_PLAYERS}/{player_id}/enabled", True) if not enabled: self.logger.debug("Ignoring disabled player: %s", player_id) return - speaker_info = await asyncio.to_thread(soco_device.get_speaker_info, True) - assert player_id not in self.sonosplayers - if soco_device not in soco_device.visible_zones: return - sonos_player = SonosPlayer( - player_id=player_id, - soco=soco_device, - player=Player( + if not (sonos_player := self.sonosplayers.get(player_id)): + self.sonosplayers[player_id] = sonos_player = SonosPlayer( + self, + soco_device, + ) + + if not (mass_player := self.mass.players.get(player_id)): + mass_player = Player( player_id=soco_device.uid, provider=self.domain, type=PlayerType.PLAYER, @@ -651,121 +726,55 @@ class SonosPlayerProvider(PlayerProvider): available=True, powered=False, supported_features=PLAYER_FEATURES, - device_info=DeviceInfo( - model=speaker_info["model_name"], - address=soco_device.ip_address, - manufacturer=self.name, - ), - max_sample_rate=441000, + device_info=DeviceInfo(), + max_sample_rate=44100, supports_24bit=False, - ), - speaker_info=speaker_info, - speaker_info_updated=time.time(), - ) - if speaker_info["model_name"] in HIRES_MODELS: - sonos_player.player.max_sample_rate = 48000 - sonos_player.player.supports_24bit = True - - # poll all endpoints once and update attributes - await sonos_player.check_poll() - sonos_player.update_attributes() - - # handle subscriptions to events - def subscribe(service, _callback): - queue = ProcessSonosEventQueue(sonos_player, _callback) - sub = service.subscribe(auto_renew=True, event_queue=queue) - sonos_player.subscriptions.append(sub) - - subscribe(soco_device.avTransport, self._handle_av_transport_event) - subscribe(soco_device.renderingControl, self._handle_rendering_control_event) - subscribe(soco_device.zoneGroupTopology, self._handle_zone_group_topology_event) - - self.sonosplayers[player_id] = sonos_player - - self.mass.players.register_or_update(sonos_player.player) - - def _handle_av_transport_event(self, sonos_player: SonosPlayer, event: SonosEvent): - """Handle a soco.SoCo AVTransport event.""" - if self.mass.closing: - return - self.logger.debug("Received AVTransport event for Player %s", sonos_player.soco.player_name) - - if "transport_state" in event.variables: - new_state = event.variables["transport_state"] - if new_state == "TRANSITIONING": - return - sonos_player.transport_info["current_transport_state"] = new_state + ) - if "current_track_uri" in event.variables: - sonos_player.transport_info["uri"] = event.variables["current_track_uri"] + await sonos_player.reconnect(soco_device) - sonos_player.transport_info_updated = time.time() - asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop) + if sonos_player.speaker_info["model_name"] in HIRES_MODELS: + mass_player.max_sample_rate = 48000 + mass_player.supports_24bit = True - def _handle_rendering_control_event(self, sonos_player: SonosPlayer, event: SonosEvent): - """Handle a soco.SoCo RenderingControl event.""" - if self.mass.closing: - return - self.logger.debug( - "Received RenderingControl event for Player %s", - sonos_player.soco.player_name, + mass_player.device_info = DeviceInfo( + model=sonos_player.speaker_info["model_name"], + address=sonos_player.soco_device.ip_address, + manufacturer="SONOS", ) - if "volume" in event.variables: - sonos_player.rendering_control_info["volume"] = event.variables["volume"]["Master"] - if "mute" in event.variables: - sonos_player.rendering_control_info["mute"] = bool(event.variables["mute"]["Master"]) - sonos_player.rendering_control_info_updated = time.time() - asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop) - def _handle_zone_group_topology_event( - self, sonos_player: SonosPlayer, event: SonosEvent # noqa: ARG002 - ): - """Handle a soco.SoCo ZoneGroupTopology event.""" - if self.mass.closing: - return - self.logger.debug( - "Received ZoneGroupTopology event for Player %s", - sonos_player.soco.player_name, - ) - sonos_player.group_info = sonos_player.soco.group - sonos_player.group_info_updated = time.time() - asyncio.run_coroutine_threadsafe(self._update_player(sonos_player), self.mass.loop) + self.mass.players.register_or_update(mass_player) async def _enqueue_item( self, sonos_player: SonosPlayer, url: str, - queue_item: QueueItem, + queue_item: QueueItem | None, ) -> None: """Enqueue a queue item to the Sonos player Queue.""" metadata = create_didl_metadata(self.mass, url, queue_item) await asyncio.to_thread( - sonos_player.soco.avTransport.AddURIToQueue, - [ - ("InstanceID", 0), - ("EnqueuedURI", url), - ("EnqueuedURIMetaData", metadata), - ("DesiredFirstTrackNumberEnqueued", 0), - ("EnqueueAsNext", 0), - ], + sonos_player.soco_device.avTransport.SetNextAVTransportURI, + [("InstanceID", 0), ("NextURI", url), ("NextURIMetaData", metadata)], timeout=60, ) - self.logger.debug( - "Enqued track (%s) to player %s", + self.logger.info( + "Enqued next track (%s) to player %s", queue_item.name if queue_item else url, - sonos_player.player.display_name, + sonos_player.soco_device.player_name, ) - async def _update_player(self, sonos_player: SonosPlayer, signal_update: bool = True) -> None: + async def update_player(self, sonos_player: SonosPlayer, signal_update: bool = True) -> None: """Update Sonos Player.""" - prev_url = sonos_player.player.current_item_id - prev_state = sonos_player.player.state + mass_player = self.mass.players.get(sonos_player.player_id) + prev_url = mass_player.current_item_id + prev_state = mass_player.state sonos_player.update_attributes() - sonos_player.player.can_sync_with = tuple( + mass_player.can_sync_with = tuple( x for x in self.sonosplayers if x != sonos_player.player_id ) - current_url = sonos_player.player.current_item_id - current_state = sonos_player.player.state + current_url = mass_player.current_item_id + current_state = mass_player.state if (prev_url != current_url) or (prev_state != current_state): # fetch track details on state or url change @@ -779,7 +788,7 @@ class SonosPlayerProvider(PlayerProvider): # send update to the player manager right away only if we are triggered from an event # when we're just updating from a manual poll, the player manager # will detect changes to the player object itself - self.mass.players.update(sonos_player.player_id) + self.mass.players.update(mass_player.player_id) def _convert_state(sonos_state: str) -> PlayerState: @@ -805,14 +814,12 @@ class ProcessSonosEventQueue: def __init__( self, - sonos_player: SonosPlayer, - callback_handler: callable[[SonosPlayer, dict], None], + callback_handler: callable[[dict], None], ) -> None: """Initialize Sonos event queue.""" self._callback_handler = callback_handler - self._sonos_player = sonos_player def put(self, info: Any, block=True, timeout=None) -> None: # noqa: ARG002 """Process event.""" # noqa: ARG001 - self._callback_handler(self._sonos_player, info) + self._callback_handler(info) -- 2.34.1