Add Bluesound player provider (#1624)
authorDiede van Marle <Contact@designedbydie.de>
Wed, 11 Sep 2024 22:35:36 +0000 (00:35 +0200)
committerGitHub <noreply@github.com>
Wed, 11 Sep 2024 22:35:36 +0000 (00:35 +0200)
music_assistant/server/helpers/util.py
music_assistant/server/providers/bluesound/__init__.py [new file with mode: 0644]
music_assistant/server/providers/bluesound/icon.svg [new file with mode: 0644]
music_assistant/server/providers/bluesound/manifest.json [new file with mode: 0644]
requirements_all.txt

index 959b599eb56b13b1c2bc6a41752ed5b85abda331..41e055744f467b053fc28c3c16ca2aacb6391de3 100644 (file)
@@ -152,6 +152,11 @@ def get_primary_ip_address_from_zeroconf(discovery_info: AsyncServiceInfo) -> st
     return None
 
 
+def get_port_from_zeroconf(discovery_info: AsyncServiceInfo) -> str | None:
+    """Get primary IP address from zeroconf discovery info."""
+    return discovery_info.port
+
+
 class TaskManager:
     """
     Helper class to run many tasks at once.
diff --git a/music_assistant/server/providers/bluesound/__init__.py b/music_assistant/server/providers/bluesound/__init__.py
new file mode 100644 (file)
index 0000000..78b5a6a
--- /dev/null
@@ -0,0 +1,394 @@
+"""Bluesound Player Provider for BluOS players to work with Music Assistant."""
+
+from __future__ import annotations
+
+import asyncio
+import time
+from typing import TYPE_CHECKING, TypedDict
+
+from pyblu import Player as BluosPlayer
+from pyblu import Status, SyncStatus
+from zeroconf import ServiceStateChange
+
+from music_assistant.common.models.config_entries import (
+    CONF_ENTRY_CROSSFADE,
+    CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
+    CONF_ENTRY_ENABLE_ICY_METADATA,
+    CONF_ENTRY_ENFORCE_MP3,
+    CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,
+    CONF_ENTRY_HTTP_PROFILE_FORCED_2,
+    ConfigEntry,
+    ConfigValueType,
+)
+from music_assistant.common.models.enums import (
+    PlayerFeature,
+    PlayerState,
+    PlayerType,
+    ProviderFeature,
+)
+from music_assistant.common.models.errors import PlayerCommandFailed
+from music_assistant.common.models.player import DeviceInfo, Player, PlayerMedia
+from music_assistant.server.helpers.util import (
+    get_port_from_zeroconf,
+    get_primary_ip_address_from_zeroconf,
+)
+from music_assistant.server.models.player_provider import PlayerProvider
+
+if TYPE_CHECKING:
+    from zeroconf.asyncio import AsyncServiceInfo
+
+    from music_assistant.common.models.config_entries import ProviderConfig
+    from music_assistant.common.models.provider import ProviderManifest
+    from music_assistant.server import MusicAssistant
+    from music_assistant.server.models import ProviderInstanceType
+
+
+PLAYER_FEATURES_BASE = {
+    PlayerFeature.SYNC,
+    PlayerFeature.VOLUME_MUTE,
+    PlayerFeature.ENQUEUE_NEXT,
+    PlayerFeature.PAUSE,
+}
+
+PLAYBACK_STATE_MAP = {
+    "play": PlayerState.PLAYING,
+    "stream": PlayerState.PLAYING,
+    "stop": PlayerState.IDLE,
+    "pause": PlayerState.PAUSED,
+    "connecting": PlayerState.IDLE,
+}
+
+SOURCE_LINE_IN = "line_in"
+SOURCE_AIRPLAY = "airplay"
+SOURCE_SPOTIFY = "spotify"
+SOURCE_UNKNOWN = "unknown"
+SOURCE_RADIO = "radio"
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize BluOS instance with given configuration."""
+    return BluesoundPlayerProvider(mass, manifest, config)
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,
+    action: str | None = None,
+    values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+    """Set up legacy BluOS devices."""
+    # ruff: noqa: ARG001
+    return ()
+
+
+class BluesoundDiscoveryInfo(TypedDict):
+    """Template for MDNS discovery info."""
+
+    _objectType: str
+    ip_address: str
+    port: str
+    mac: str
+    model: str
+    zs: bool
+
+
+class BluesoundPlayer:
+    """Holds the details of the (discovered) BluOS player."""
+
+    def __init__(
+        self,
+        prov: BluesoundPlayerProvider,
+        player_id: str,
+        discovery_info: BluesoundDiscoveryInfo,
+        ip_address: str,
+        port: int,
+    ) -> None:
+        """Initialize the BluOS Player."""
+        self.port = port
+        self.prov = prov
+        self.mass = prov.mass
+        self.player_id = player_id
+        self.discovery_info = discovery_info
+        self.ip_address = ip_address
+        self.logger = prov.logger.getChild(player_id)
+        self.connected: bool = True
+        self.client = BluosPlayer(self.ip_address, self.port, self.mass.http_session)
+        self.sync_status = SyncStatus
+        self.status = Status
+        self.mass_player: Player | None = None
+        self._listen_task: asyncio.Task | None = None
+
+    async def disconnect(self) -> None:
+        """Disconnect the BluOS client and cleanup."""
+        if self._listen_task and not self._listen_task.done():
+            self._listen_task.cancel()
+        if self.client:
+            await self.client.close()
+        self.connected = False
+        self.logger.debug("Disconnected from player API")
+
+    async def update_attributes(self) -> None:
+        """Update the BluOS player attributes."""
+        self.logger.debug("Update attributes")
+
+        self.sync_status = await self.client.sync_status()
+        self.status = await self.client.status()
+
+        # Update timing
+        self.mass_player.elapsed_time = self.status.seconds
+        self.mass_player.elapsed_time_last_updated = time.time()
+
+        if not self.mass_player:
+            return
+        if self.sync_status.volume == -1:
+            self.mass_player.volume_level = 100
+        else:
+            self.mass_player.volume_level = self.sync_status.volume
+        self.mass_player.volume_muted = self.status.mute
+
+        if self.status.state == "stream":
+            mass_active = self.mass.streams.base_url
+        if self.status.state == "stream" and self.status.input_id == "input0":
+            self.mass_player.active_source = SOURCE_LINE_IN
+        elif self.status.state == "stream" and self.status.input_id == "Airplay":
+            self.mass_player.active_source = SOURCE_AIRPLAY
+        elif self.status.state == "stream" and self.status.input_id == "Spotify":
+            self.mass_player.active_source = SOURCE_SPOTIFY
+        elif self.status.state == "stream" and self.status.input_id == "RadioParadise":
+            self.mass_player.active_source = SOURCE_RADIO
+        elif self.status.state == "stream" and (mass_active not in self.status.stream_url):
+            self.logger.debug("mass_active")
+            self.mass_player.active_source = SOURCE_UNKNOWN
+
+        # TODO check pair status
+
+        # TODO fix pairing
+
+        if self.sync_status.master is None:
+            if self.sync_status.slaves:
+                self.mass_player.group_childs = (
+                    self.sync_status.slaves if len(self.sync_status.slaves) > 1 else set()
+                )
+                self.mass_player.synced_to = None
+
+            if self.status.state == "stream":
+                self.mass_player.current_media = PlayerMedia(
+                    uri=self.status.stream_url,
+                    title=self.status.name,
+                    artist=self.status.artist,
+                    album=self.status.album,
+                    image_url=self.status.image,
+                )
+            else:
+                self.mass_player.current_media = None
+
+        else:
+            self.mass_player.group_childs = set()
+            self.mass_player.synced_to = self.sync_status.master
+            self.mass_player.active_source = self.sync_status.master
+
+        self.mass_player.state = PLAYBACK_STATE_MAP[self.status.state]
+        self.mass_player.can_sync_with = (
+            tuple(x for x in self.prov.bluos_players if x != self.player_id),
+        )
+
+        self.mass.players.update(self.player_id)
+
+
+class BluesoundPlayerProvider(PlayerProvider):
+    """Bluos compatible player provider, providing support for bluesound speakers."""
+
+    bluos_players: dict[str, BluesoundPlayer]
+
+    @property
+    def supported_features(self) -> tuple[ProviderFeature, ...]:
+        """Return the features supported by this Provider."""
+        return (ProviderFeature.SYNC_PLAYERS,)
+
+    async def handle_async_init(self) -> None:
+        """Handle async initialization of the provider."""
+        self.bluos_players: dict[str, BluosPlayer] = {}
+
+    async def on_mdns_service_state_change(
+        self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
+    ) -> None:
+        """Handle MDNS service state callback for BluOS."""
+        name = name.split(".", 1)[0]
+        self.player_id = info.decoded_properties["mac"]
+        # Handle removed player
+
+        if state_change == ServiceStateChange.Removed:
+            # Check if the player manager has an existing entry for this player
+            if mass_player := self.mass.players.get(self.player_id):
+                # The player has become unavailable
+                self.logger.debug("Player offline: %s", mass_player.display_name)
+                mass_player.available = False
+                self.mass.players.update(self.player_id)
+            return
+
+        if bluos_player := self.bluos_players.get(self.player_id):
+            if mass_player := self.mass.players.get(self.player_id):
+                cur_address = get_primary_ip_address_from_zeroconf(info)
+                cur_port = get_port_from_zeroconf(info)
+                if cur_address and cur_address != mass_player.device_info.address:
+                    self.logger.debug(
+                        "Address updated to %s for player %s", cur_address, mass_player.display_name
+                    )
+                    bluos_player.ip_address = cur_address
+                    bluos_player.port = cur_port
+                    mass_player.device_info = DeviceInfo(
+                        model=mass_player.device_info.model,
+                        manufacturer=mass_player.device_info.manufacturer,
+                        address=str(cur_address),
+                    )
+                if not mass_player.available:
+                    self.logger.debug("Player back online: %s", mass_player.display_name)
+                    bluos_player.client.sync()
+                    mass_player.available = True
+                bluos_player.discovery_info = info
+                self.mass.players.update(self.player_id)
+                return
+            # handle new player
+        cur_address = get_primary_ip_address_from_zeroconf(info)
+        cur_port = get_port_from_zeroconf(info)
+        self.logger.debug("Discovered device %s on %s", name, cur_address)
+
+        self.bluos_players[self.player_id] = bluos_player = BluesoundPlayer(
+            self, self.player_id, discovery_info=info, ip_address=cur_address, port=cur_port
+        )
+
+        bluos_player.mass_player = mass_player = Player(
+            player_id=self.player_id,
+            provider=self.instance_id,
+            type=PlayerType.PLAYER,
+            name=name,
+            available=True,
+            powered=True,
+            device_info=DeviceInfo(
+                model="BluOS speaker",
+                manufacturer="Bluesound",
+                address=cur_address,
+            ),
+            # Set the supported features for this player
+            supported_features=(
+                PlayerFeature.VOLUME_SET,
+                PlayerFeature.VOLUME_MUTE,
+                PlayerFeature.PLAY_ANNOUNCEMENT,
+                PlayerFeature.ENQUEUE_NEXT,
+                PlayerFeature.PAUSE,
+            ),
+            needs_poll=True,
+            poll_interval=30,
+        )
+        self.mass.players.register(mass_player)
+
+        # TODO sync
+        await bluos_player.update_attributes()
+        self.mass.players.update(self.player_id)
+
+    async def get_player_config_entries(
+        self,
+        player_id: str,
+    ) -> tuple[ConfigEntry, ...]:
+        """Return Config Entries for the given player."""
+        base_entries = await super().get_player_config_entries(self.player_id)
+        if not self.bluos_players.get(self.player_id):
+            # TODO fix player entries
+            return (*base_entries, CONF_ENTRY_CROSSFADE)
+        return (
+            *base_entries,
+            CONF_ENTRY_HTTP_PROFILE_FORCED_2,
+            CONF_ENTRY_CROSSFADE,
+            CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
+            CONF_ENTRY_ENFORCE_MP3,
+            CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,
+            CONF_ENTRY_ENABLE_ICY_METADATA,
+        )
+
+    async def cmd_stop(self, player_id: str) -> None:
+        """Send STOP command to BluOS player."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.stop()
+            mass_player = self.mass.players.get(player_id)
+            # Optimistic state, reduces interface lag
+            mass_player.state = PLAYBACK_STATE_MAP["stop"]
+            await bluos_player.update_attributes()
+
+    async def cmd_play(self, player_id: str) -> None:
+        """Send PLAY command to BluOS player."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.play()
+            # Optimistic state, reduces interface lag
+            mass_player = self.mass.players.get(player_id)
+            mass_player.state = PLAYBACK_STATE_MAP["play"]
+            await bluos_player.update_attributes()
+
+    async def cmd_pause(self, player_id: str) -> None:
+        """Send PAUSE command to BluOS player."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.pause()
+            # Optimistic state, reduces interface lag
+            mass_player = self.mass.players.get(player_id)
+            mass_player.state = PLAYBACK_STATE_MAP["pause"]
+            await bluos_player.update_attributes()
+
+    async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+        """Send VOLUME_SET command to BluOS player."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.volume(level=volume_level)
+            mass_player = self.mass.players.get(player_id)
+            # Optimistic state, reduces interface lag
+            mass_player.volume_level = volume_level
+            await bluos_player.update_attributes()
+
+    async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+        """Send VOLUME MUTE command to BluOS player."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.volume(mute=muted)
+            # Optimistic state, reduces interface lag
+            mass_player = self.mass.players.get(player_id)
+            mass_player.volume_mute = muted
+            await bluos_player.update_attributes()
+
+    async def play_media(
+        self, player_id: str, media: PlayerMedia, timeout: float | None = None
+    ) -> None:
+        """Handle PLAY MEDIA for BluOS player using the provided URL."""
+        mass_player = self.mass.players.get(player_id)
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.play_url(media.uri, timeout=timeout)
+            # Update media info then optimistically override playback state and source
+            await bluos_player.update_attributes()
+            mass_player.state = PLAYBACK_STATE_MAP["play"]
+            mass_player.active_source = None
+            self.mass.players.update(player_id)
+
+        mass_player = self.mass.players.get(player_id)
+
+        # Optionally, handle the playback_state or additional logic here
+        if mass_player.state != "playing":
+            raise PlayerCommandFailed("Failed to start playback.")
+
+    async def play_announcement(
+        self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None
+    ) -> None:
+        """Send announcement to player."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.Input(announcement.uri, volume_level)
+
+    async def poll_player(self, player_id: str) -> None:
+        """Poll player for state updates."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.update_attributes()
+
+    # TODO fix sync & unsync
+
+    async def cmd_sync(self, player_id: str, target_player: str) -> None:
+        """Handle SYNC command for BluOS player."""
+
+    async def cmd_unsync(self, player_id: str) -> None:
+        """Handle UNSYNC command for BluOS player."""
+        if bluos_player := self.bluos_players[player_id]:
+            await bluos_player.client.player.leave_group()
diff --git a/music_assistant/server/providers/bluesound/icon.svg b/music_assistant/server/providers/bluesound/icon.svg
new file mode 100644 (file)
index 0000000..2cb9d37
--- /dev/null
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   viewBox="0 0 300 300"
+   version="1.1"
+   id="svg2"
+   sodipodi:docname="bluos.svg"
+   xml:space="preserve"
+   inkscape:export-filename="bluos.svg"
+   inkscape:export-xdpi="48"
+   inkscape:export-ydpi="48"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"><defs
+     id="defs2" /><sodipodi:namedview
+     id="namedview2"
+     pagecolor="#ffffff"
+     bordercolor="#000000"
+     borderopacity="0.25"
+     inkscape:showpageshadow="2"
+     inkscape:pageopacity="0.0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#d1d1d1"
+     showguides="true"
+     inkscape:zoom="3.9095958"
+     inkscape:cx="289.92767"
+     inkscape:cy="123.54218"
+     inkscape:window-width="2880"
+     inkscape:window-height="1715"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg2" /><rect
+     style="fill:#000000"
+     id="rect3"
+     width="300"
+     height="300"
+     x="0"
+     y="0"
+     inkscape:label="rect3" /><path
+     fill="currentColor"
+     d="m 169.36756,174.07934 -69.882704,0.025 a 37.862849,37.862849 0 0 0 -37.83788,37.81291 c 0,17.06283 0.0583,31.1043 0.0583,31.1043 v 6.85843 H 169.53407 l 1.58976,-0.0166 c 38.93656,-0.82401 67.22757,-29.77257 67.22757,-68.85895 a 72.68768,72.68768 0 0 0 -6.70028,-30.97112 72.995643,72.995643 0 0 0 6.70028,-30.9961 c 0,-39.078052 -28.29101,-68.026593 -67.28584,-68.875573 L 61.705276,50.120017 v 6.933321 c 0,0 -0.0583,13.991525 -0.0583,31.071007 a 37.854525,37.854525 0 0 0 37.83788,37.796255 l 69.907674,0.10821 c 19.76792,0 36.55609,8.72285 46.33601,24.0045 -9.80489,15.29829 -26.6097,24.04611 -46.36098,24.04611 m 1.82281,-62.02549 -71.705514,0.0166 A 23.971203,23.971203 0 0 1 75.521976,88.124269 V 63.978276 h 93.870554 c 31.91999,0 55.10048,23.155516 55.10048,55.058854 0,5.66819 -0.76575,11.14494 -2.25563,16.39697 -12.2353,-14.36608 -30.53831,-22.88917 -51.04701,-23.38025 m 51.04701,52.55354 c 1.48988,5.23537 2.25563,10.7371 2.25563,16.39696 0,31.8867 -23.18049,55.02557 -55.10048,55.02557 l -93.870554,0.0166 v -24.12935 a 23.98785,23.98785 0 0 1 23.96288,-23.96288 h 70.298874 l 2.39712,-0.12485 c 20.07588,-0.69916 37.9877,-9.05579 50.05653,-23.2221"
+     id="path1"
+     style="fill:#ffffff;stroke-width:8.32333" /></svg>
diff --git a/music_assistant/server/providers/bluesound/manifest.json b/music_assistant/server/providers/bluesound/manifest.json
new file mode 100644 (file)
index 0000000..8c38260
--- /dev/null
@@ -0,0 +1,10 @@
+{
+    "type": "player",
+    "domain": "bluesound",
+    "name": "Bluesound",
+    "description": "BluOS Player provider for Music Assistant.",
+    "codeowners": ["@cyanogenbot"],
+    "requirements": ["pyblu==0.4.0"],
+    "documentation": "Link to the documentation on the music-assistant.io helppage (may be added later).",
+    "mdns_discovery": ["_musc._tcp.local."]
+}
index 3c62f593c76abfa185ee014d09481ac15c432f60..171ad3361aada08f4c6ebd119e4cd3997262e859 100644 (file)
@@ -28,6 +28,7 @@ pillow==10.4.0
 pkce==1.0.3
 plexapi==4.15.16
 py-opensonic==5.1.1
+pyblu==0.4.0
 PyChromecast==14.0.1
 pycryptodome==3.20.0
 python-fullykiosk==0.0.14