/
/
/
1"""
2Sonos Player provider for Music Assistant for speakers running the S2 firmware.
3
4Based on the aiosonos library, which leverages the new websockets API of the Sonos S2 firmware.
5https://github.com/music-assistant/aiosonos
6"""
7
8from __future__ import annotations
9
10import logging
11from typing import TYPE_CHECKING, Any, cast
12
13from aiohttp import web
14from aiohttp.client_exceptions import ClientError
15from aiosonos.api.models import SonosCapability
16from aiosonos.utils import get_discovery_info
17from music_assistant_models.enums import EventType, IdentifierType
18from music_assistant_models.errors import InvalidDataError
19from zeroconf import ServiceStateChange
20
21from music_assistant.constants import (
22 CONF_ENTRY_MANUAL_DISCOVERY_IPS,
23 CONF_LOG_LEVEL,
24 MASS_LOGO_ONLINE,
25 VERBOSE_LOG_LEVEL,
26)
27from music_assistant.helpers.audio import get_mime_type
28from music_assistant.helpers.json import SerializableType
29from music_assistant.models.player_provider import PlayerProvider
30
31from .helpers import get_primary_ip_address
32from .player import SonosPlayer, SonosQueueWindow
33
34# stands in for "the speaker named no ceiling", so our own window size decides
35_NO_CEILING = 1000
36
37if TYPE_CHECKING:
38 from collections.abc import Callable
39
40 from music_assistant_models.config_entries import ConfigEntry, ProviderConfig
41 from music_assistant_models.event import MassEvent
42 from music_assistant_models.player import PlayerMedia
43 from zeroconf.asyncio import AsyncServiceInfo
44
45
46def _requested_max(requested: str | None) -> int:
47 """
48 Return the ceiling a speaker put on one side of the window.
49
50 The sizes are maxima: we serve fewer by design, but never more. An absent or unreadable
51 size puts no ceiling on it.
52 """
53 try:
54 return max(0, int(requested)) if requested is not None else _NO_CEILING
55 except ValueError:
56 return _NO_CEILING
57
58
59def _refresh_task_id(player_id: str) -> str:
60 """Return the debounce id for a speaker's pending cloud-queue refresh."""
61 return f"sonos_refresh_cloud_queue_{player_id}"
62
63
64class SonosPlayerProvider(PlayerProvider):
65 """Sonos Player provider."""
66
67 _ignored_disabled_players: set[str]
68 _pending_setup_tasks: set[str]
69 _pending_refresh_tasks: set[str]
70 _unloaded: bool
71 _unsub_queue_items_updated: Callable[[], None] | None = None
72
73 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
74 """Return Config entries to setup this provider."""
75 return (CONF_ENTRY_MANUAL_DISCOVERY_IPS,)
76
77 async def handle_async_init(self) -> None:
78 """Handle async initialization of the provider."""
79 self._ignored_disabled_players = set()
80 self._pending_setup_tasks = set()
81 self._pending_refresh_tasks = set()
82 self._unloaded = False
83 self._set_aiosonos_log_level()
84 self.mass.streams.register_dynamic_route(
85 "/sonos_queue/*", self._handle_sonos_cloud_queue_request
86 )
87 self._unsub_queue_items_updated = self.mass.subscribe(
88 self._handle_queue_items_updated, EventType.QUEUE_ITEMS_UPDATED
89 )
90
91 async def loaded_in_mass(self) -> None:
92 """Call after the provider has been loaded."""
93 await super().loaded_in_mass()
94 # Handle config option for manual IP's
95 manual_ip_config = cast(
96 "list[str]", self.config.get_value(CONF_ENTRY_MANUAL_DISCOVERY_IPS.key)
97 )
98 for ip_address in manual_ip_config:
99 try:
100 # get discovery info from SONOS speaker so we can provide an ID & other info
101 discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, ip_address)
102 except ClientError as err:
103 self.logger.debug(
104 "Ignoring %s (manual IP) as it is not reachable: %s", ip_address, str(err)
105 )
106 continue
107 player_id = discovery_info["device"]["id"]
108 sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
109 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, ip_address)
110 await sonos_player.setup()
111
112 async def unload(self, is_removed: bool = False) -> None:
113 """Handle close/cleanup of the provider."""
114 self._unloaded = True
115 self.mass.streams.unregister_dynamic_route("/sonos_queue/*")
116 if self._unsub_queue_items_updated is not None:
117 self._unsub_queue_items_updated()
118 self._unsub_queue_items_updated = None
119 for task_id in self._pending_setup_tasks | self._pending_refresh_tasks:
120 # a timer that already fired lives on as a task under the same id,
121 # so both are needed to cover the pending and the running case
122 self.mass.cancel_timer(task_id)
123 self.mass.cancel_task(task_id)
124 self._pending_setup_tasks.clear()
125 self._pending_refresh_tasks.clear()
126
127 async def update_config(self, config: ProviderConfig, changed_keys: set[str]) -> None:
128 """Handle logic when the config is updated."""
129 await super().update_config(config, changed_keys)
130 # a log level(-only) change does not reload the provider,
131 # so realign aiosonos's logger here
132 if f"values/{CONF_LOG_LEVEL}" in changed_keys:
133 self._set_aiosonos_log_level()
134
135 async def get_diagnostics(self) -> dict[str, SerializableType]:
136 """Return diagnostics info for this provider to include in diagnostics reports."""
137 sonos_players = [player for player in self.players if isinstance(player, SonosPlayer)]
138 # active_output_protocol holds "native" or the player id of the protocol player in use
139 active_protocols = [
140 player.active_output_protocol
141 for player in sonos_players
142 if player.active_output_protocol
143 ]
144 return {
145 "speakers_total": len(sonos_players),
146 "speakers_connected": sum(player.connected for player in sonos_players),
147 "coordinators": sum(
148 player.client.player.is_coordinator for player in sonos_players if player.connected
149 ),
150 "native_playback": sum(protocol == "native" for protocol in active_protocols),
151 "protocol_playback": sum(protocol != "native" for protocol in active_protocols),
152 }
153
154 async def on_mdns_service_state_change(
155 self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
156 ) -> None:
157 """Handle MDNS service state callback."""
158 if self._unloaded:
159 # discovery resolves an announcement before it dispatches it, so a callback
160 # picked up before the unload can still arrive after it
161 return
162 if state_change == ServiceStateChange.Removed:
163 # we don't listen for removed players here.
164 # instead we just wait for the player connection to fail
165 return
166 assert info is not None # for type checking
167 if "uuid" not in info.decoded_properties:
168 # not a S2 player
169 return
170 name = name.split("@", 1)[1] if "@" in name else name
171 player_id = info.decoded_properties["uuid"]
172 assert isinstance(player_id, str) # for type checking
173 # handle update for existing device
174 if sonos_player := self.mass.players.get_player(player_id):
175 assert isinstance(sonos_player, SonosPlayer), (
176 "Player ID already exists but is not a SonosPlayer"
177 )
178 # if mass_player := sonos_player.mass_player:
179 cur_address = get_primary_ip_address(info)
180 if cur_address and cur_address != sonos_player.device_info.ip_address:
181 sonos_player.logger.debug(
182 "Address updated from %s to %s",
183 sonos_player.device_info.ip_address,
184 cur_address,
185 )
186 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, cur_address)
187 if not sonos_player.connected and cur_address:
188 self.logger.debug("Player back online: %s", sonos_player.display_name)
189 sonos_player.client.player_ip = cur_address
190 # schedule reconnect
191 sonos_player.reconnect()
192 self.mass.players.trigger_player_update(player_id)
193 return
194 if self._ignore_disabled_discovery(player_id, name):
195 return
196 # handle new player setup in a delayed task because mdns announcements
197 # can arrive in (duplicated) bursts
198 task_id = f"setup_sonos_{player_id}"
199 self._pending_setup_tasks.add(task_id)
200 self.mass.call_later(5, self._setup_player, player_id, name, info, task_id=task_id)
201
202 def _handle_queue_items_updated(self, event: MassEvent) -> None:
203 """Tell the speakers playing a queue that its contents changed."""
204 for player in self.players:
205 if not isinstance(player, SonosPlayer) or player.cloud_queue_id != event.object_id:
206 continue
207 # invalidate straight away, so a window served before the command goes out does
208 # not carry a version the speaker reads as current
209 player.bump_cloud_queue_version()
210 # one edit can fan out into several of these (insert, autoplay refill, a duration
211 # filled in), so coalesce the command itself into one per speaker
212 task_id = _refresh_task_id(player.player_id)
213 self._pending_refresh_tasks.add(task_id)
214 self.mass.call_later(1, player.refresh_cloud_queue, task_id=task_id)
215
216 def _set_aiosonos_log_level(self) -> None:
217 """Align aiosonos's log level with the provider's log level."""
218 # aiosonos is very chatty at debug level, so only pass through its
219 # debug logging when verbose logging is enabled
220 if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
221 logging.getLogger("aiosonos").setLevel(logging.DEBUG)
222 else:
223 logging.getLogger("aiosonos").setLevel(self.logger.level + 10)
224
225 async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None:
226 """Handle setup of a new player that is discovered using mdns."""
227 if self.mass.players.get_player(player_id):
228 msg = f"Player {player_id} already exists"
229 raise ValueError(msg)
230 if self._ignore_disabled_discovery(player_id, name):
231 return
232 address = get_primary_ip_address(info)
233 if address is None:
234 return
235 try:
236 discovery_info = await get_discovery_info(self.mass.http_session_no_ssl, address)
237 except ClientError as err:
238 self.logger.debug("Ignoring %s in discovery as it is not reachable: %s", name, str(err))
239 return
240 display_name = discovery_info["device"].get("name") or name
241 if SonosCapability.PLAYBACK not in discovery_info["device"]["capabilities"]:
242 # this will happen for satellite speakers in a surround/stereo setup
243 self.logger.debug(
244 "Ignoring %s in discovery as it is a passive satellite.", display_name
245 )
246 return
247 self.logger.debug("Discovered Sonos device %s on %s", name, address)
248 sonos_player = SonosPlayer(self, player_id, discovery_info=discovery_info)
249 sonos_player.device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
250 await sonos_player.setup()
251
252 def _ignore_disabled_discovery(self, player_id: str, name: str) -> bool:
253 """
254 Return whether discovery should ignore a disabled player.
255
256 :param player_id: The discovered Sonos player ID.
257 :param name: The discovered Sonos service name.
258 """
259 if self.mass.config.get_raw_player_config_value(player_id, "enabled", True):
260 self._ignored_disabled_players.discard(player_id)
261 return False
262 if player_id not in self._ignored_disabled_players:
263 self.logger.debug("Ignoring %s in discovery as it is disabled.", name)
264 self._ignored_disabled_players.add(player_id)
265 return True
266
267 async def _handle_sonos_cloud_queue_request(self, request: web.Request) -> web.Response:
268 """
269 Handle the Sonos CloudQueue request.
270
271 https://docs.sonos.com/reference/itemwindow
272 """
273 self.logger.log(
274 VERBOSE_LOG_LEVEL,
275 "Cloud Queue request\n - path: %s\n - query: %s\n",
276 request.path,
277 request.query,
278 )
279 path_parts = request.path.strip("/").split("/")
280 if len(path_parts) != 4 or path_parts[0] != "sonos_queue":
281 return web.Response(status=404)
282 player_id = path_parts[1]
283 if not (sonos_player := self.mass.players.get_player(player_id)):
284 return web.Response(status=501)
285 if TYPE_CHECKING:
286 assert isinstance(sonos_player, SonosPlayer)
287 endpoint = path_parts[3]
288 if endpoint == "itemWindow":
289 return await self._handle_sonos_queue_itemwindow(sonos_player, request)
290 if endpoint == "version":
291 return await self._handle_sonos_queue_version(sonos_player, request)
292 if endpoint == "context":
293 return await self._handle_sonos_queue_context(sonos_player, request)
294 if endpoint == "timePlayed":
295 return await self._handle_sonos_queue_time_played(sonos_player, request)
296 return web.Response(status=404)
297
298 async def _handle_sonos_queue_itemwindow(
299 self, player: SonosPlayer, request: web.Request
300 ) -> web.Response:
301 """
302 Handle the Sonos CloudQueue ItemWindow endpoint.
303
304 https://docs.sonos.com/reference/itemwindow
305 """
306 context_version = request.query.get("contextVersion", "1")
307 # read the version before building, so the items and the version we label them with
308 # always come from the same queue: a bump landing in between would tell the speaker
309 # its cache is current while it holds the older window
310 queue_version = player.cloud_queue_version
311 # built from the queue as it is right now: the speaker fetches on its own schedule and
312 # plays out of what it cached, so only a live answer keeps a track added mid-playback
313 # from being played over. The beginning/end flags must be honest - signalling
314 # end-of-queue is what makes Sonos drop items it cached past our window, so a queue
315 # rewrite (replace_next) does not resurrect stale tracks.
316 try:
317 window = await player.build_cloud_queue_window(
318 request.query.get("itemId") or None,
319 max_previous=_requested_max(request.query.get("previousWindowSize")),
320 max_upcoming=_requested_max(request.query.get("upcomingWindowSize")),
321 )
322 except InvalidDataError as err:
323 # the queue went away under us (a stop that never reached this speaker, so it keeps
324 # polling): end-of-queue is the right answer and beats a 500 per poll. Only this
325 # one - any other failure must not read to the speaker as "queue over".
326 self.logger.debug("Cannot describe the queue for %s: %s", player.display_name, err)
327 window = SonosQueueWindow(includes_beginning=True, includes_end=True)
328 result = {
329 "includesBeginningOfQueue": window.includes_beginning,
330 "includesEndOfQueue": window.includes_end,
331 "contextVersion": context_version,
332 # report the version of the items we actually serve instead of echoing the
333 # player's requested version, otherwise a changed queue keeps a stale version
334 # label and Sonos never realises it changed.
335 "queueVersion": str(queue_version),
336 "items": [self._parse_sonos_queue_item(x) for x in window.items],
337 }
338 return web.json_response(result)
339
340 async def _handle_sonos_queue_version(
341 self, player: SonosPlayer, request: web.Request
342 ) -> web.Response:
343 """
344 Handle the Sonos CloudQueue Version endpoint.
345
346 https://docs.sonos.com/reference/version
347 """
348 context_version = request.query.get("contextVersion") or "1"
349 # keep sub-second resolution: the queue can change several times within the same
350 # second and Sonos treats an unchanged queueVersion as "nothing changed" (stale window).
351 result = {
352 "contextVersion": context_version,
353 "queueVersion": str(player.cloud_queue_version),
354 }
355 return web.json_response(result)
356
357 async def _handle_sonos_queue_context(
358 self, player: SonosPlayer, request: web.Request
359 ) -> web.Response:
360 """
361 Handle the Sonos CloudQueue Context endpoint.
362
363 https://docs.sonos.com/reference/context
364 """
365 result = {
366 "contextVersion": "1",
367 "queueVersion": str(player.cloud_queue_version),
368 "container": {
369 "type": "trackList",
370 "name": "Music Assistant",
371 "imageUrl": MASS_LOGO_ONLINE,
372 "service": {"name": "Music Assistant", "id": "mass"},
373 "id": {
374 "serviceId": "mass",
375 "objectId": f"mass:{player.cloud_queue_id or 'unknown'}",
376 "accountId": "",
377 },
378 },
379 "reports": {
380 "sendUpdateAfterMillis": 1000,
381 "periodicIntervalMillis": 30000,
382 "sendPlaybackActions": True,
383 },
384 "playbackPolicies": {
385 "canSkip": True,
386 "limitedSkips": True,
387 "canSkipToItem": True, # unsure
388 "canSkipBack": True,
389 # seek needs to be disabled because we dont properly support range requests
390 "canSeek": False,
391 "canRepeat": False, # handled by MA queue controller
392 "canRepeatOne": False, # synced from MA queue controller
393 "canCrossfade": False, # handled by MA queue controller
394 "canShuffle": False, # handled by MA queue controller
395 },
396 }
397 return web.json_response(result)
398
399 async def _handle_sonos_queue_time_played(
400 self, player: SonosPlayer, request: web.Request
401 ) -> web.Response:
402 """
403 Handle the Sonos CloudQueue TimePlayed endpoint.
404
405 https://docs.sonos.com/reference/timeplayed
406 """
407 json_body = await request.json()
408 for item in json_body["items"]:
409 if item["type"] != "update":
410 continue
411 if "positionMillis" not in item:
412 continue
413 if player.current_media and player.current_media.queue_item_id == item["id"]:
414 player.update_elapsed_time(item["positionMillis"] / 1000)
415 break
416 return web.Response(status=204)
417
418 def _parse_sonos_queue_item(self, media: PlayerMedia) -> dict[str, Any]:
419 """Parse MusicAssistant PlayerMedia to a Sonos Media (queue) object."""
420 # the speaker tracks its position within the audio we serve, which is
421 # shorter than the media item when playback starts at a seek position
422 duration = media.stream_duration or media.duration
423 return {
424 "id": media.queue_item_id or media.uri,
425 "track": {
426 "type": "track",
427 "mediaUrl": media.uri,
428 "contentType": get_mime_type(media.uri.split(".")[-1]),
429 "service": {"name": "Music Assistant", "id": "mass"},
430 "name": media.title,
431 "imageUrl": media.image_url,
432 "durationMillis": int(duration * 1000) if duration else 0,
433 "artist": {
434 "name": media.artist,
435 }
436 if media.artist
437 else None,
438 "album": {
439 "name": media.album,
440 }
441 if media.album
442 else None,
443 },
444 }
445