Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Some more tweaks/fixes to the Sonos S2 provider #1613

Merged
merged 3 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 134 additions & 15 deletions music_assistant/server/providers/sonos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast

import shortuuid
from aiohttp import web
Expand All @@ -15,6 +15,7 @@
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

Expand All @@ -26,13 +27,16 @@
create_sample_rates_config_entry,
)
from music_assistant.common.models.enums import (
ConfigEntryType,
EventType,
PlayerFeature,
PlayerState,
PlayerType,
ProviderFeature,
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,
Expand Down Expand Up @@ -71,6 +75,8 @@
SOURCE_UNKNOWN = "unknown"
SOURCE_RADIO = "radio"

CONF_AIRPLAY_MODE = "airplay_mode"


async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
Expand Down Expand Up @@ -130,6 +136,22 @@ def __init__(
self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
self.queue_version: str = shortuuid.random(8)

@property
def airplay_mode(self) -> bool:
"""Return if the player is in airplay mode."""
return cast(
bool,
self.mass.config.get_raw_player_config_value(self.player_id, CONF_AIRPLAY_MODE, False),
)

def get_linked_airplay_player(self, active_only: bool = True) -> Player | None:
"""Return the linked airplay player if available/enabled."""
if active_only and not self.airplay_mode:
return None
if airplay_player := self.mass.players.get(self.airplay_player_id):
return airplay_player
return None

async def setup(self) -> None:
"""Handle setup of the player."""
# connect the player first so we can fail early
Expand Down Expand Up @@ -175,6 +197,12 @@ async def setup(self) -> None:
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."""
Expand Down Expand Up @@ -217,27 +245,46 @@ async def cmd_stop(self) -> None:
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()
if airplay := self.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 self.client.player.group.stop()
except FailedCommand as err:
if "ERROR_PLAYBACK_NO_CONTENT" not in str(err):
raise

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
if airplay := self.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 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
if airplay := self.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 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):
# sync volume level with airplay player
if airplay := self.get_linked_airplay_player(False):
if airplay.state not in (PlayerState.PLAYING, PlayerState.PAUSED):
airplay.volume_level = volume_level

Expand Down Expand Up @@ -289,6 +336,15 @@ def update_attributes(self) -> None: # noqa: PLR0915
self.mass_player.active_source = active_group.coordinator_id
self.mass_player.can_sync_with = ()

if (airplay := self.get_linked_airplay_player(True)) and airplay.powered:
# 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 (
Expand All @@ -299,13 +355,17 @@ def update_attributes(self) -> None: # noqa: PLR0915

self.mass_player.elapsed_time = active_group.position

is_playing = active_group.playback_state in (
SonosPlayBackState.PLAYBACK_STATE_PLAYING,
SonosPlayBackState.PLAYBACK_STATE_BUFFERING,
)
# figure out the active source based on the container
container_type = active_group.container_type
active_service = active_group.active_service
container = active_group.playback_metadata.get("container")
if container_type == ContainerType.LINEIN:
if is_playing and container_type == ContainerType.LINEIN:
self.mass_player.active_source = SOURCE_LINE_IN
elif container_type == ContainerType.AIRPLAY:
elif is_playing and 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 (
Expand All @@ -315,18 +375,25 @@ def update_attributes(self) -> None: # noqa: PLR0915
self.mass_player.active_source = airplay_player.active_source
else:
self.mass_player.active_source = SOURCE_AIRPLAY
elif container_type == ContainerType.STATION:
elif is_playing and container_type == ContainerType.STATION:
self.mass_player.active_source = SOURCE_RADIO
elif active_service == MusicService.SPOTIFY:
elif is_playing and 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")):
if (
active_group.active_session_id
and 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
self.mass_player.active_source = None
elif is_playing:
# its playing some service we did not yet map
self.mass_player.active_source = active_service
else:
# all our (known) options exhausted, fallback to unknown
self.mass_player.active_source = active_service
self.mass_player.active_source = None

if self.mass_player.active_source == self.player_id and active_group.active_session_id:
# active source is the mass queue
Expand Down Expand Up @@ -377,6 +444,15 @@ def _on_player_event(self, event: SonosEvent) -> None:
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.airplay_mode:
return
if event.object_id != self.airplay_player_id:
return
self.update_attributes()
self.mass.players.update(self.player_id)


class SonosPlayerProvider(PlayerProvider):
"""Sonos Player provider."""
Expand All @@ -386,7 +462,7 @@ class SonosPlayerProvider(PlayerProvider):
@property
def supported_features(self) -> tuple[ProviderFeature, ...]:
"""Return the features supported by this Provider."""
return (ProviderFeature.SYNC_PLAYERS, ProviderFeature.PLAYER_GROUP_CREATE)
return (ProviderFeature.SYNC_PLAYERS,)

async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
Expand Down Expand Up @@ -467,14 +543,33 @@ async def get_player_config_entries(
) -> tuple[ConfigEntry, ...]:
"""Return Config Entries for the given player."""
base_entries = await super().get_player_config_entries(player_id)
if not (self.sonos_players.get(player_id)):
if not (sonos_player := 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 (
*base_entries,
CONF_ENTRY_CROSSFADE,
CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
create_sample_rates_config_entry(48000, 24, 48000, 24, True),
ConfigEntry(
key=CONF_AIRPLAY_MODE,
type=ConfigEntryType.BOOLEAN,
label="Enable 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.\n\n"
"By default, Music Assistant uses the Sonos protocol for playback but with this "
"feature enabled, it will use the Airplay protocol instead by redirecting "
"the playback related commands to the linked Airplay player in Music Assistant, "
"allowing you to mix and match Sonos speakers with Airplay speakers. \n\n"
"TIP: When this feature is enabled, it make sense to set the underlying airplay "
"players to hide in the UI in the player settings to prevent duplicate players.",
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:
Expand Down Expand Up @@ -546,6 +641,25 @@ async def play_media(
)
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.")
mass_player.active_source = airplay.active_source
# Sonos has an annoying bug (for years already, and they dont seem to care),
# where it looses its sync childs when airplay playback is (re)started.
# Try to handle it here with this workaround.
group_childs = (
sonos_player.client.player.group_members
if len(sonos_player.client.player.group_members) > 1
else []
)
if group_childs:
await self.cmd_unsync_many(group_childs)
await self.mass.players.play_media(airplay.player_id, media)
if group_childs:
self.mass.call_later(5, self.cmd_sync_many(player_id, group_childs))
return

if media.queue_id:
# create a sonos cloud queue and load it
await sonos_player.client.player.group.create_playback_session()
Expand All @@ -566,11 +680,16 @@ async def play_media(
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 sonos_player.get_linked_airplay_player(True):
# linked airplay player is active, ignore this command
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
mass_queue = self.mass.player_queues.get(media.queue_id)
crossfade = await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE)
crossfade = await self.mass.config.get_player_config_value(
mass_queue.queue_id, CONF_CROSSFADE
)
repeat_single_enabled = mass_queue.repeat_mode == RepeatMode.ONE
repeat_all_enabled = mass_queue.repeat_mode == RepeatMode.ALL
play_modes = sonos_player.client.player.group.play_modes
Expand Down Expand Up @@ -660,7 +779,7 @@ async def _handle_sonos_queue_itemwindow(self, request: web.Request) -> web.Resp
return web.Response(status=501)
offset = max(queue_index - previous_window_size, 0)
queue_items = self.mass.player_queues.items(
sonos_player_id,
mass_queue.queue_id,
limit=upcoming_window_size + previous_window_size,
offset=max(queue_index - previous_window_size, 0),
)
Expand Down
2 changes: 1 addition & 1 deletion music_assistant/server/providers/sonos/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "SONOS",
"description": "SONOS Player provider for Music Assistant.",
"codeowners": ["@music-assistant"],
"requirements": ["aiosonos==0.1.3"],
"requirements": ["aiosonos==0.1.4"],
"documentation": "https://music-assistant.io/player-support/sonos/",
"multi_instance": false,
"builtin": false,
Expand Down
2 changes: 1 addition & 1 deletion music_assistant/server/providers/sonos_s1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class SonosPlayerProvider(PlayerProvider):
@property
def supported_features(self) -> tuple[ProviderFeature, ...]:
"""Return the features supported by this Provider."""
return (ProviderFeature.SYNC_PLAYERS, ProviderFeature.PLAYER_GROUP_CREATE)
return (ProviderFeature.SYNC_PLAYERS,)

async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ aiohttp==3.10.4
aiojellyfin==0.10.1
aiorun==2024.8.1
aioslimproto==3.0.1
aiosonos==0.1.3
aiosonos==0.1.4
aiosqlite==0.20.0
async-upnp-client==0.40.0
bidict==0.23.1
Expand Down