import asyncio
import logging
import time
-from typing import TYPE_CHECKING, Final
+from typing import TYPE_CHECKING
+import shortuuid
from aiohttp import web
+from aiosonos.api.models import ContainerType, MusicService, SonosCapability
from aiosonos.api.models import DiscoveryInfo as SonosDiscoveryInfo
from aiosonos.api.models import PlayBackState as SonosPlayBackState
-from aiosonos.api.models import SonosCapability
from aiosonos.client import SonosLocalApiClient
from aiosonos.const import EventType as SonosEventType
from aiosonos.const import SonosEvent
-from aiosonos.exceptions import FailedCommand
from aiosonos.utils import get_discovery_info
from zeroconf import IPVersion, ServiceStateChange
create_sample_rates_config_entry,
)
from music_assistant.common.models.enums import (
- ConfigEntryType,
- EventType,
PlayerFeature,
PlayerState,
PlayerType,
RepeatMode,
)
from music_assistant.common.models.errors import PlayerCommandFailed
-from music_assistant.common.models.event import MassEvent
from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia
from music_assistant.constants import (
CONF_CROSSFADE,
SOURCE_UNKNOWN = "unknown"
SOURCE_RADIO = "radio"
-CONF_SMART_AIRPLAY: Final[str] = "smart_airplay"
-
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
self.mass_player: Player | None = None
self._listen_task: asyncio.Task | None = None
# Sonos speakers can optionally have airplay (most S2 speakers do)
- # and this airplay player can also be a player within MA
- # we can do some smart stuff if we link them together where possible
- # the player if we can just guess from the sonos player id (mac address)
+ # and this airplay player can also be a player within MA.
+ # We can do some smart stuff if we link them together where possible.
+ # The player we can just guess from the sonos player id (mac address).
self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
- self.stream_mode: str = "sonos"
-
- def get_linked_airplay_player(self, active_only: bool = True) -> Player | None:
- """Return the linked airplay player if available/enabled."""
- if not self.mass.config.get_raw_player_config_value(
- self.player_id, CONF_SMART_AIRPLAY, False
- ):
- return None
- if not (airplay := self.mass.players.get(self.airplay_player_id)):
- return None
- if not airplay.available:
- return None
- if not active_only:
- return airplay
- if (airplay.powered and airplay.group_childs) or airplay.state == PlayerState.PLAYING:
- # only when the airplay player has synced members,
- # it makes sense to link it to the sonos player
- return airplay
- return None
+ self.queue_version: str = shortuuid.random(8)
async def setup(self) -> None:
"""Handle setup of the player."""
SonosEventType.PLAYER_UPDATED,
),
)
- # register callback for airplay player state changes
- self.mass.subscribe(
- self._on_airplay_player_event,
- (EventType.PLAYER_UPDATED, EventType.PLAYER_ADDED),
- self.airplay_player_id,
- )
async def connect(self) -> None:
"""Connect to the Sonos player."""
await self.client.disconnect()
self.logger.debug("Disconnected from player API")
+ async def cmd_stop(self) -> None:
+ """Send STOP command to given player."""
+ if self.client.player.is_passive:
+ self.logger.debug("Ignore STOP command: Player is synced to another player.")
+ return
+ await self.client.player.group.stop()
+
+ async def cmd_play(self) -> None:
+ """Send PLAY command to given player."""
+ if self.client.player.is_passive:
+ self.logger.debug("Ignore STOP command: Player is synced to another player.")
+ return
+ await self.client.player.group.play()
+
+ async def cmd_pause(self) -> None:
+ """Send PAUSE command to given player."""
+ if self.client.player.is_passive:
+ self.logger.debug("Ignore STOP command: Player is synced to another player.")
+ return
+ await self.client.player.group.pause()
+
+ async def cmd_volume_set(self, volume_level: int) -> None:
+ """Send VOLUME_SET command to given player."""
+ await self.client.player.set_volume(volume_level)
+ # sync volume level with airplay player if linked
+ if airplay := self.mass.players.get(self.airplay_player_id):
+ if airplay.state not in (PlayerState.PLAYING, PlayerState.PAUSED):
+ airplay.volume_level = volume_level
+
+ async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+ """Send VOLUME MUTE command to given player."""
+ await self.client.player.set_volume(muted=muted)
+
def update_attributes(self) -> None: # noqa: PLR0915
"""Update the player attributes."""
if not self.mass_player:
# player is group coordinator
active_group = self.client.player.group
self.mass_player.group_childs = (
- self.client.player.group_members
+ set(self.client.player.group_members)
if len(self.client.player.group_members) > 1
else set()
)
self.mass_player.synced_to = None
- if airplay := self.get_linked_airplay_player(False):
- self.mass_player.group_childs.update(
- x for x in airplay.group_childs if x != self.airplay_player_id
- )
else:
# player is group child (synced to another player)
group_parent = self.prov.sonos_players.get(self.client.player.group.coordinator_id)
self.mass_player.active_source = active_group.coordinator_id
self.mass_player.can_sync_with = ()
- if self.stream_mode == "airplay" and (airplay := self.get_linked_airplay_player(True)):
- # linked airplay player is active, update media from there
- self.mass_player.state = airplay.state
- self.mass_player.powered = airplay.powered
- self.mass_player.active_source = airplay.active_source
- self.mass_player.elapsed_time = airplay.elapsed_time
- self.mass_player.elapsed_time_last_updated = airplay.elapsed_time_last_updated
- return
-
# map playback state
self.mass_player.state = PLAYBACK_STATE_MAP[active_group.playback_state]
if (
self.mass_player.elapsed_time = active_group.position
# figure out the active source based on the container
- is_playing = active_group.playback_state in (
- SonosPlayBackState.PLAYBACK_STATE_PLAYING,
- SonosPlayBackState.PLAYBACK_STATE_BUFFERING,
- SonosPlayBackState.PLAYBACK_STATE_PAUSED,
- )
- if container := active_group.playback_metadata.get("container"):
- if group_parent and group_parent.mass_player:
- self.mass_player.active_source = group_parent.mass_player.active_source
- elif (
- container.get("type") == "linein"
- and active_group.playback_state == SonosPlayBackState.PLAYBACK_STATE_PLAYING
+ container_type = active_group.container_type
+ active_service = active_group.active_service
+ container = active_group.playback_metadata.get("container")
+ if container_type == ContainerType.LINEIN:
+ self.mass_player.active_source = SOURCE_LINE_IN
+ elif container_type == ContainerType.AIRPLAY:
+ # check if the MA airplay player is active
+ airplay_player = self.mass.players.get(self.airplay_player_id)
+ if airplay_player and airplay_player.state in (
+ PlayerState.PLAYING,
+ PlayerState.PAUSED,
):
- self.mass_player.active_source = SOURCE_LINE_IN
- elif is_playing and container.get("type") == "linein.airplay":
- # check if the MA airplay player is active
- airplay_player = self.mass.players.get(self.airplay_player_id)
- if airplay_player and airplay_player.powered:
- self.mass_player.active_source = airplay_player.active_source
- else:
- self.mass_player.active_source = SOURCE_AIRPLAY
- elif is_playing and container.get("type") == "station":
- self.mass_player.active_source = SOURCE_RADIO
- elif container.get("id", {}).get("objectId") == f"mass:queue:{self.player_id}":
- # mass queue is active
- self.mass_player.active_source = self.player_id
- elif is_playing and container.get("id", {}).get("serviceId") == "9":
- self.mass_player.active_source = SOURCE_SPOTIFY
- elif is_playing:
- self.mass_player.active_source = container.get("type", SOURCE_UNKNOWN)
+ self.mass_player.active_source = airplay_player.active_source
+ else:
+ self.mass_player.active_source = SOURCE_AIRPLAY
+ elif container_type == ContainerType.STATION:
+ self.mass_player.active_source = SOURCE_RADIO
+ elif active_service == MusicService.SPOTIFY:
+ self.mass_player.active_source = SOURCE_SPOTIFY
+ elif active_service == MusicService.MUSIC_ASSISTANT:
+ if container and (object_id := container.get("id", {}).get("objectId")):
+ self.mass_player.active_source = object_id.split(":")[-1]
else:
self.mass_player.active_source = self.player_id
+ else:
+ # all our (known) options exhausted, fallback to unknown
+ self.mass_player.active_source = active_service
if self.mass_player.active_source == self.player_id and active_group.active_session_id:
# active source is the mass queue
self.update_attributes()
self.mass.players.update(self.player_id)
- def _on_airplay_player_event(self, event: MassEvent) -> None:
- """Handle incoming event from linked airplay player."""
- if not self.mass.config.get_raw_player_config_value(
- self.player_id, CONF_SMART_AIRPLAY, False
- ):
- return
- if event.object_id != self.airplay_player_id:
- return
- airplay_player: Player = event.data
- if (
- self.stream_mode == "sonos"
- and airplay_player.group_childs
- and self.mass_player.state == PlayerState.PLAYING
- and airplay_player.state != PlayerState.PLAYING
- and self.mass_player.active_source == self.player_id
- and not self.mass_player.synced_to
- ):
- # linked airplay player became active, transfer playback to playback
- self.logger.info(
- "Linked Airplay player became active, transferring playback.",
- )
- self.stream_mode = "airplay"
- self.mass.create_task(self.mass.player_queues.resume(self.player_id))
- elif (
- self.stream_mode == "airplay"
- and not airplay_player.group_childs
- and airplay_player.state == PlayerState.PLAYING
- ):
- # linked airplay player became active, transfer playback to sonos
- self.logger.info(
- "Linked Airplay player became idle, transferring playback.",
- )
- self.stream_mode = "sonos"
- self.mass.create_task(self.mass.players.cmd_power(self.airplay_player_id, False))
- self.mass.create_task(self.mass.player_queues.resume(self.player_id))
- else:
- self.update_attributes()
- self.mass.players.update(self.player_id)
-
class SonosPlayerProvider(PlayerProvider):
"""Sonos Player provider."""
if not sonos_player.connected:
self.logger.debug("Player back online: %s", mass_player.display_name)
sonos_player.client.player_ip = cur_address
- await sonos_player.connect(self.mass.http_session)
+ await sonos_player.connect()
self.mass.players.update(player_id)
return
# handle new player
) -> tuple[ConfigEntry, ...]:
"""Return Config Entries for the given player."""
base_entries = await super().get_player_config_entries(player_id)
- if not (sonos_player := self.sonos_players.get(player_id)):
+ if not (self.sonos_players.get(player_id)):
# most probably a syncgroup or the player is not yet discovered
return (*base_entries, CONF_ENTRY_CROSSFADE, CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED)
return (
CONF_ENTRY_CROSSFADE,
CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
create_sample_rates_config_entry(48000, 24, 48000, 24, True),
- ConfigEntry(
- key=CONF_SMART_AIRPLAY,
- type=ConfigEntryType.BOOLEAN,
- label="Enable smart Airplay mode (experimental)",
- description="Almost all newer Sonos speakers have Airplay support. "
- "If you have the Airplay provider enabled in Music Assistant, "
- "your Sonos speakers will also be detected as Airplay speakers, meaning "
- "you can group them with other Airplay speakers."
- "The downside of that is that you'll end up with a duplicated player. \n\n"
- "This feature will try to circumvent that by linking the Sonos "
- "player to the Airplay player: \n\nIf the AirPlay player is powered on, "
- "and has other players grouped to it or is playing, any playback related commands "
- "will be automatically redirected to the Airplay player. \n\n"
- "Effectively this will mean that the Sonos player holds the queue and for "
- "playback, Music Assistant will automatically pick either "
- "the Sonos or the Airplay protocol.",
- required=False,
- default_value=False,
- hidden=SonosCapability.AIRPLAY
- not in sonos_player.discovery_info["device"]["capabilities"],
- ),
)
async def cmd_stop(self, player_id: str) -> None:
"""Send STOP command to given player."""
- sonos_player = self.sonos_players[player_id]
- if sonos_player.client.player.is_passive:
- self.logger.debug(
- "Ignore STOP command for %s: Player is synced to another player.",
- player_id,
- )
- return
- if airplay := sonos_player.get_linked_airplay_player(True):
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting STOP command to linked airplay player.")
- await self.mass.players.cmd_stop(airplay.player_id)
- return
- try:
- await sonos_player.client.player.group.stop()
- except FailedCommand:
- # no session to stop, fallback to pause
- await self.mass.players.cmd_pause(player_id)
+ if sonos_player := self.sonos_players[player_id]:
+ await sonos_player.cmd_stop()
async def cmd_play(self, player_id: str) -> None:
"""Send PLAY command to given player."""
- sonos_player = self.sonos_players[player_id]
- if sonos_player.client.player.is_passive:
- self.logger.debug(
- "Ignore PLAY command for %s: Player is synced to another player.",
- player_id,
- )
- return
- if airplay := sonos_player.get_linked_airplay_player(True):
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting PLAY command to linked airplay player.")
- await self.mass.players.cmd_play(airplay.player_id)
- return
- await sonos_player.client.player.group.play()
+ if sonos_player := self.sonos_players[player_id]:
+ await sonos_player.cmd_play()
async def cmd_pause(self, player_id: str) -> None:
"""Send PAUSE command to given player."""
- sonos_player = self.sonos_players[player_id]
- if sonos_player.client.player.is_passive:
- self.logger.debug(
- "Ignore PLAY command for %s: Player is synced to another player.",
- player_id,
- )
- return
- if airplay := sonos_player.get_linked_airplay_player(True):
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting PAUSE command to linked airplay player.")
- await self.mass.players.cmd_pause(airplay.player_id)
- return
- await sonos_player.client.player.group.pause()
+ if sonos_player := self.sonos_players[player_id]:
+ await sonos_player.cmd_pause()
async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
"""Send VOLUME_SET command to given player."""
- sonos_player = self.sonos_players[player_id]
- await sonos_player.client.player.set_volume(volume_level)
- # sync volume level with airplay player if linked
- if airplay := sonos_player.get_linked_airplay_player(False):
- airplay.volume_level = volume_level
+ if sonos_player := self.sonos_players[player_id]:
+ await sonos_player.cmd_volume_set(volume_level)
async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
"""Send VOLUME MUTE command to given player."""
async def cmd_sync_many(self, target_player: str, child_player_ids: list[str]) -> None:
"""Create temporary sync group by joining given players to target player."""
sonos_player = self.sonos_players[target_player]
- if airplay_child_ids := [x for x in child_player_ids if x.startswith("ap")]:
- # handle airplay players
- await self.mass.players.cmd_sync_many(sonos_player.airplay_player_id, airplay_child_ids)
- if sonos_child_ids := [x for x in child_player_ids if not x.startswith("ap")]:
- await sonos_player.client.player.group.modify_group_members(
- player_ids_to_add=sonos_child_ids, player_ids_to_remove=[]
- )
+ await sonos_player.client.player.group.modify_group_members(
+ player_ids_to_add=child_player_ids, player_ids_to_remove=[]
+ )
async def cmd_unsync(self, player_id: str) -> None:
"""Handle UNSYNC command for given player.
) -> None:
"""Handle PLAY MEDIA on given player."""
sonos_player = self.sonos_players[player_id]
+ sonos_player.queue_version = shortuuid.random(8)
mass_player = self.mass.players.get(player_id)
if sonos_player.client.player.is_passive:
# this should be already handled by the player manager, but just in case...
)
raise PlayerCommandFailed(msg)
- if airplay := sonos_player.get_linked_airplay_player(True):
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting PLAY_MEDIA command to linked airplay player.")
- self.stream_mode = "airplay"
- mass_player.active_source = airplay.player_id
- # TODO: sonos has an annoying bug where it looses its sync childs
- # when airplay playback is started
- await self.mass.players.play_media(airplay.player_id, media)
- return
-
if media.queue_id:
# create a sonos cloud queue and load it
- self.stream_mode = "sonos"
await sonos_player.client.player.group.create_playback_session()
- mass_queue = self.mass.player_queues.get(media.queue_id)
cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
await sonos_player.client.player.group.play_cloud_queue(
cloud_queue_url,
http_authorization=media.queue_id,
item_id=media.queue_item_id,
- queue_version=mass_queue.queue_version,
+ queue_version=sonos_player.queue_version,
)
return
# play a single uri/url
- self.stream_mode = "sonos"
await sonos_player.client.player.group.play_stream_url(
media.uri, {"name": media.title, "type": "track"}
)
async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
"""Handle enqueuing of the next queue item on the player."""
sonos_player = self.sonos_players[player_id]
- if airplay := sonos_player.get_linked_airplay_player(True):
- # linked airplay player is active, redirect the command
- self.logger.debug("Redirecting ENQUEUE_NEXT command to linked airplay player.")
- await self.mass.players.enqueue_next_media(airplay.player_id, media)
- return
-
if session_id := sonos_player.client.player.group.active_session_id:
await sonos_player.client.api.playback_session.refresh_cloud_queue(session_id)
# sync play modes from player queue --> sonos
self.logger.debug(
"Playing announcement %s using websocket audioclip on %s",
announcement.uri,
- sonos_player.zone_name,
+ sonos_player.mass_player.display_name,
)
volume_level = self.mass.players.get_announcement_volume(player_id, volume_level)
- await sonos_player.client.player.play_audio_clip(announcement.uri, volume_level)
+ await sonos_player.client.player.play_audio_clip(
+ announcement.uri, volume_level, name="Announcement"
+ )
async def _setup_player(self, player_id: str, name: str, info: AsyncServiceInfo) -> None:
"""Handle setup of a new player that is discovered using mdns."""
if item_id := request.query.get("itemId"):
queue_index = self.mass.player_queues.index_by_id(mass_queue.queue_id, item_id)
else:
- queue_index = mass_queue.current_index or 0
+ queue_index = mass_queue.current_index
+ if queue_index is None:
+ return web.Response(status=501)
offset = max(queue_index - previous_window_size, 0)
queue_items = self.mass.player_queues.items(
sonos_player_id,
else None,
"durationMillis": item.duration * 1000 if item.duration else None,
"artist": {
- "name": item.media_item.artist_str,
+ "name": artist_str,
}
- if item.media_item and hasattr(item.media_item, "artist_str")
+ if item.media_item
+ and (artist_str := getattr(item.media_item, "artist_str", None))
else None,
"album": {
- "name": item.media_item.album.name,
+ "name": album.name,
}
- if item.media_item and hasattr(item.media_item, "album")
+ if item.media_item and (album := getattr(item.media_item, "album", None))
else None,
"quality": {
"bitDepth": item.streamdetails.audio_format.bit_depth,
self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue Version request: %s", request.query)
sonos_playback_id = request.headers["X-Sonos-Playback-Id"]
sonos_player_id = sonos_playback_id.split(":")[0]
- if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)):
+ if not (sonos_player := self.sonos_players.get(sonos_player_id)):
return web.Response(status=501)
context_version = request.query.get("contextVersion") or "1"
- queue_version = mass_queue.queue_version
+ queue_version = sonos_player.queue_version
result = {"contextVersion": context_version, "queueVersion": queue_version}
return web.json_response(result)
sonos_player_id = sonos_playback_id.split(":")[0]
if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)):
return web.Response(status=501)
+ if not (sonos_player := self.sonos_players.get(sonos_player_id)):
+ return web.Response(status=501)
result = {
"contextVersion": "1",
- "queueVersion": mass_queue.queue_version,
+ "queueVersion": sonos_player.queue_version,
"container": {
"type": "playlist",
"name": "Music Assistant",
sonos_player_id = sonos_playback_id.split(":")[0]
if not (mass_player := self.mass.players.get(sonos_player_id)):
return web.Response(status=501)
- if not (mass_queue := self.mass.player_queues.get(mass_player.active_source)):
+ if not (sonos_player := self.sonos_players.get(sonos_player_id)):
return web.Response(status=501)
for item in json_body["items"]:
- if item["queueVersion"] != mass_queue.queue_version:
+ if item["queueVersion"] != sonos_player.queue_version:
continue
if "positionMillis" not in item:
continue