Add Snapcast Player Provider (#916)
authorSantiago Soto <81064015+SantiagoSotoC@users.noreply.github.com>
Thu, 23 Nov 2023 14:23:41 +0000 (11:23 -0300)
committerGitHub <noreply@github.com>
Thu, 23 Nov 2023 14:23:41 +0000 (15:23 +0100)
music_assistant/server/providers/snapcast/__init__.py [new file with mode: 0644]
music_assistant/server/providers/snapcast/icon.svg [new file with mode: 0644]
music_assistant/server/providers/snapcast/manifest.json [new file with mode: 0644]
requirements_all.txt

diff --git a/music_assistant/server/providers/snapcast/__init__.py b/music_assistant/server/providers/snapcast/__init__.py
new file mode 100644 (file)
index 0000000..85fc53a
--- /dev/null
@@ -0,0 +1,316 @@
+"""Snapcast Player provider for Music Assistant."""
+from __future__ import annotations
+
+import asyncio
+import time
+import uuid
+from typing import TYPE_CHECKING
+
+from ffmpeg import FFmpegError, Progress
+from ffmpeg.asyncio import FFmpeg
+from snapcast.control import create_server
+from snapcast.control.client import Snapclient as SnapClient
+from snapcast.control.group import Snapgroup as SnapGroup
+from snapcast.control.stream import Snapstream as SnapStream
+
+from music_assistant.common.models.config_entries import ConfigEntry, ConfigValueType
+from music_assistant.common.models.enums import (
+    ConfigEntryType,
+    PlayerFeature,
+    PlayerState,
+    PlayerType,
+)
+from music_assistant.common.models.errors import SetupFailedError
+from music_assistant.common.models.player import DeviceInfo, Player
+from music_assistant.common.models.queue_item import QueueItem
+from music_assistant.server.models.player_provider import PlayerProvider
+
+if TYPE_CHECKING:
+    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
+CONF_SNAPCAST_SERVER_HOST = "snapcast_server_host"
+CONF_SNAPCAST_SERVER_CONTROL_PORT = "snapcast_server_control_port"
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    prov = SnapCastProvider(mass, manifest, config)
+    await prov.handle_setup()
+    return prov
+
+
+async def get_config_entries(
+    mass: MusicAssistant,
+    instance_id: str | None = None,
+    action: str | None = None,
+    values: dict[str, ConfigValueType] | None = None,
+) -> tuple[ConfigEntry, ...]:
+    """
+    Return Config entries to setup this provider.
+
+    instance_id: id of an existing provider instance (None if new instance setup).
+    action: [optional] action key called from config entries UI.
+    values: the (intermediate) raw values for config entries sent with the action.
+    """
+    # ruff: noqa: ARG001
+    return (
+        ConfigEntry(
+            key=CONF_SNAPCAST_SERVER_HOST,
+            type=ConfigEntryType.STRING,
+            default_value="127.0.0.1",
+            label="Snapcast server ip",
+            required=True,
+        ),
+        ConfigEntry(
+            key=CONF_SNAPCAST_SERVER_CONTROL_PORT,
+            type=ConfigEntryType.INTEGER,
+            default_value="1705",
+            label="Snapcast control port",
+            required=True,
+        ),
+    )
+
+
+class SnapCastProvider(PlayerProvider):
+    """Player provider for Snapcast based players."""
+
+    _snapserver: [asyncio.Server | asyncio.BaseTransport]
+    snapcast_server_host: str
+    snapcast_server_control_port: int
+
+    async def handle_setup(self) -> None:
+        """Handle async initialization of the provider."""
+        self.snapcast_server_host = self.config.get_value(CONF_SNAPCAST_SERVER_HOST)
+        self.snapcast_server_control_port = self.config.get_value(CONF_SNAPCAST_SERVER_CONTROL_PORT)
+        try:
+            self._snapserver = await create_server(
+                self.mass.loop,
+                self.snapcast_server_host,
+                port=self.snapcast_server_control_port,
+                reconnect=True,
+            )
+            self._snapserver.set_on_update_callback(self._handle_update)
+            self._handle_update()
+            self.logger.info(
+                f"Started Snapserver connection on:"
+                f"{self.snapcast_server_host}:{self.snapcast_server_control_port}"
+            )
+        except OSError:
+            raise SetupFailedError("Unable to start the Snapserver connection ?")
+
+    def _handle_update(self) -> None:
+        """Process Snapcast init Player/Group and set callback ."""
+        for snap_client in self._snapserver.clients:
+            self._handle_player_init(snap_client)
+            snap_client.set_callback(self._handle_player_update)
+        for snap_client in self._snapserver.clients:
+            self._handle_player_update(snap_client)
+        for snap_group in self._snapserver.groups:
+            snap_group.set_callback(self._handle_group_update)
+
+    def _handle_group_update(self, snap_group: SnapGroup) -> None:  # noqa: ARG002
+        """Process Snapcast group callback."""
+        for snap_client in self._snapserver.clients:
+            self._handle_player_update(snap_client)
+
+    def _handle_player_init(self, snap_client: SnapClient) -> None:
+        """Process Snapcast add to Player controller."""
+        player_id = snap_client.identifier
+        player = self.mass.players.get(player_id, raise_unavailable=False)
+        if not player:
+            snap_client = self._snapserver.client(player_id)
+            player = Player(
+                player_id=player_id,
+                provider=self.domain,
+                type=PlayerType.PLAYER,
+                name=snap_client.friendly_name,
+                available=True,
+                powered=snap_client.connected,
+                device_info=DeviceInfo(
+                    model=snap_client._client.get("host").get("os"),
+                    address=snap_client._client.get("host").get("ip"),
+                    manufacturer=snap_client._client.get("host").get("arch"),
+                ),
+                supported_features=(
+                    PlayerFeature.SYNC,
+                    PlayerFeature.VOLUME_SET,
+                    PlayerFeature.VOLUME_MUTE,
+                ),
+            )
+        self.mass.players.register_or_update(player)
+
+    def _handle_player_update(self, snap_client: SnapClient) -> None:
+        """Process Snapcast update to Player controller."""
+        player_id = snap_client.identifier
+        player = self.mass.players.get(player_id)
+        player.name = snap_client.friendly_name
+        player.volume_level = snap_client.volume
+        player.volume_muted = snap_client.muted
+        player.available = snap_client.connected
+        player.can_sync_with = tuple(
+            x.identifier for x in self._snapserver.clients if x.identifier != player_id
+        )
+        player.synced_to = self._synced_to(player_id)
+        player.group_childs = self._group_childs(player_id)
+        self.mass.players.register_or_update(player)
+
+    async def unload(self) -> None:
+        """Handle close/cleanup of the provider."""
+        for client in self._snapserver.clients:
+            await self.cmd_stop(client.identifier)
+        self._snapserver.stop()
+
+    async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+        """Send VOLUME_SET command to given player."""
+        mass_player = self.mass.players.get(player_id)
+        if mass_player.volume_level != volume_level:
+            await self._snapserver.client_volume(
+                player_id, {"percent": volume_level, "muted": mass_player.volume_muted}
+            )
+            self.cmd_volume_mute(player_id, False)
+
+    async def cmd_play_url(
+        self,
+        player_id: str,
+        url: str,
+        queue_item: QueueItem | None,  # noqa: ARG002
+    ) -> None:
+        """Send PLAY URL command to given player.
+
+        This is called when the Queue wants the player to start playing a specific url.
+        If an item from the Queue is being played, the QueueItem will be provided with
+        all metadata present.
+
+            - player_id: player_id of the player to handle the command.
+            - url: the url that the player should start playing.
+            - queue_item: the QueueItem that is related to the URL (None when playing direct url).
+        """
+        player = self.mass.players.get(player_id)
+        stream = self._get_snapstream(player_id)
+        if stream.path != "":
+            await self._get_snapgroup(player_id).set_stream(await self._get_empty_stream())
+
+        stream_host = stream._stream.get("uri").get("host")
+        ffmpeg = (
+            FFmpeg()
+            .option("y")
+            .option("re")
+            .input(url=url)
+            .output(
+                f"tcp://{stream_host}",
+                f="u16le",
+                acodec="pcm_s16le",
+                ac=2,
+                ar=48000,
+            )
+        )
+        self.mass.create_task(ffmpeg.execute())
+
+        @ffmpeg.on("start")
+        async def on_start(arguments: list[str]):
+            self.logger.debug("Ffmpeg stream is running")
+            if hasattr(stream, "ffmpeg"):
+                await self.cmd_stop(player_id)
+            stream.ffmpeg = ffmpeg
+            player.state = PlayerState.IDLE
+
+            self.mass.players.register_or_update(player)
+
+        @ffmpeg.on("progress")
+        def on_progress(progress: Progress):
+            player.state = PlayerState.PLAYING
+            if player.current_url != url:
+                player.current_url = url
+                player.elapsed_time = 0
+                player.elapsed_time_last_updated = time.time()
+            self.mass.players.register_or_update(player)
+
+        @ffmpeg.on("completed")
+        async def on_completed():
+            player.state = PlayerState.IDLE
+            self.mass.players.register_or_update(player)
+
+        @ffmpeg.on("terminated")
+        async def on_terminated():
+            player.state = PlayerState.IDLE
+            self.mass.players.register_or_update(player)
+
+    async def cmd_stop(self, player_id: str) -> None:
+        """Send STOP command to given player."""
+        player = self.mass.players.get(player_id, raise_unavailable=False)
+        if player.state != PlayerState.IDLE:
+            stream = self._get_snapstream(player_id)
+            if hasattr(stream, "ffmpeg"):
+                try:
+                    stream.ffmpeg.terminate()
+                    self.logger.debug("ffmpeg player stopped")
+                except FFmpegError:
+                    self.logger.debug("Fail to stop ffmpeg player")
+
+    async def cmd_pause(self, player_id: str) -> None:
+        """Send PAUSE command to given player."""
+        await self.cmd_stop(player_id)
+
+    async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+        """Send MUTE command to given player."""
+        await self._snapserver.client(player_id).set_muted(muted)
+
+    async def cmd_sync(self, player_id: str, target_player: str) -> None:
+        """Sync Snapcast player."""
+        group = self._get_snapgroup(target_player)
+        await group.add_client(player_id)
+
+    async def cmd_unsync(self, player_id: str) -> None:
+        """Unsync Snapcast player."""
+        group = self._get_snapgroup(player_id)
+        await group.remove_client(player_id)
+        group = self._get_snapgroup(player_id)
+        stream_id = await self._get_empty_stream()
+        await group.set_stream(stream_id)
+        self._handle_update()
+
+    def _get_snapgroup(self, player_id: str) -> SnapGroup:
+        """Get snapcast group for given player_id."""
+        client = self._snapserver.client(player_id)
+        return client.group
+
+    def _get_snapstream(self, player_id: str) -> SnapStream:
+        """Get snapcast stream for given player_id."""
+        group = self._get_snapgroup(player_id)
+        return self._snapserver.stream(group.stream)
+
+    def _synced_to(self, player_id: str) -> str | None:
+        """Return player_id of the player this player is synced to."""
+        snap_group = self._get_snapgroup(player_id)
+        if player_id != snap_group.clients[0] and snap_group.clients[0].connected:
+            return snap_group.clients[0]
+
+    def _group_childs(self, player_id: str) -> set[str]:
+        """Return player_ids of the players synced to this player."""
+        snap_group = self._get_snapgroup(player_id)
+        return {snap_client for snap_client in snap_group.clients if snap_client != player_id}
+
+    async def _get_empty_stream(self) -> str:
+        """Find or create empty stream on snapcast server.
+
+        This method ensures that there is a snapstream for each snapclient,
+        even if the snapserver only have one stream configured. This is needed
+        because the default config of snapserver is one stream on a named pipe.
+        """
+        used_streams = {group.stream for group in self._snapserver.groups}
+        for stream in self._snapserver.streams:
+            if stream.path == "" and stream.identifier not in used_streams:
+                return stream.identifier
+        port = 4953
+        name = str(uuid.uuid4())
+        while True:
+            port += 1
+            new_stream = await self._snapserver.stream_add_stream(
+                f"tcp://{self.snapcast_server_host}:{port}?name={name}"
+            )
+            if new_stream["id"] not in used_streams:
+                return new_stream["id"]
diff --git a/music_assistant/server/providers/snapcast/icon.svg b/music_assistant/server/providers/snapcast/icon.svg
new file mode 100644 (file)
index 0000000..0306957
--- /dev/null
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40px" height="40px" viewBox="0 0 40 40" version="1.1">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,81.568627%,0%);fill-opacity:1;" d="M 20 0 C 31.046875 0 40 8.953125 40 20 C 40 31.046875 31.046875 40 20 40 C 8.953125 40 0 31.046875 0 20 C 0 8.953125 8.953125 0 20 0 Z M 20 0 "/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 28.021875 15.689062 C 27.848437 15.689062 27.679687 15.75 27.54375 15.857812 L 22.3125 20.189062 L 17.709375 20.189062 C 17.296875 20.189062 16.959375 20.521875 16.959375 20.939062 L 16.959375 26.657812 C 16.964062 27.070312 17.296875 27.403125 17.709375 27.407812 L 17.728125 27.407812 L 22.678125 27.31875 L 27.520312 31.715625 C 27.65625 31.8375 27.839062 31.907812 28.021875 31.907812 C 28.129687 31.907812 28.232812 31.884375 28.33125 31.842187 C 28.598437 31.720312 28.776562 31.453125 28.771875 31.157812 L 28.771875 16.439062 C 28.771875 16.148437 28.607812 15.88125 28.340625 15.759375 C 28.242187 15.7125 28.134375 15.689062 28.021875 15.689062 Z M 28.021875 15.689062 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 7.884375 15.15 C 10.9875 9.80625 16.6875 6.515625 22.865625 6.501562 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 23.142187 41.49375 C 16.964062 41.484375 11.259375 38.19375 8.160937 32.85 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 37.865625 15.15 C 40.954687 20.503125 40.954687 27.09375 37.865625 32.446875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 10.354687 16.528125 C 12.960937 12.0375 17.751562 9.271875 22.940625 9.2625 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 22.940625 38.329687 C 17.751562 38.320312 12.960937 35.554687 10.359375 31.06875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 35.3625 16.528125 C 37.959375 21.023437 37.959375 26.56875 35.3625 31.064062 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 12.815625 17.925 C 14.896875 14.339062 18.721875 12.13125 22.865625 12.121875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 22.865625 35.470312 C 18.721875 35.460937 14.896875 33.253125 12.815625 29.671875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:0.864;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 33.042187 17.995312 C 35.109375 21.585937 35.109375 26.00625 33.042187 29.596875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 28.021875 15.689062 C 27.848437 15.689062 27.679687 15.75 27.54375 15.857812 L 22.3125 20.189062 L 17.709375 20.189062 C 17.296875 20.189062 16.959375 20.521875 16.959375 20.939062 L 16.959375 26.657812 C 16.964062 27.070312 17.296875 27.403125 17.709375 27.407812 L 17.728125 27.407812 L 22.678125 27.31875 L 27.520312 31.715625 C 27.65625 31.8375 27.839062 31.907812 28.021875 31.907812 C 28.129687 31.907812 28.232812 31.884375 28.33125 31.842187 C 28.598437 31.720312 28.776562 31.453125 28.771875 31.157812 L 28.771875 16.439062 C 28.771875 16.148437 28.607812 15.88125 28.340625 15.759375 C 28.242187 15.7125 28.134375 15.689062 28.021875 15.689062 Z M 28.021875 15.689062 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 7.884375 15.15 C 10.9875 9.80625 16.6875 6.515625 22.865625 6.501562 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 23.142187 41.49375 C 16.964062 41.484375 11.259375 38.19375 8.160937 32.85 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 37.865625 15.15 C 40.954687 20.503125 40.954687 27.09375 37.865625 32.446875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 10.354687 16.528125 C 12.960937 12.0375 17.751562 9.271875 22.940625 9.2625 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 22.940625 38.329687 C 17.751562 38.320312 12.960937 35.554687 10.359375 31.06875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 35.3625 16.528125 C 37.959375 21.023437 37.959375 26.56875 35.3625 31.064062 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 12.815625 17.925 C 14.896875 14.339062 18.721875 12.13125 22.865625 12.121875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 22.865625 35.470312 C 18.721875 35.460937 14.896875 33.253125 12.815625 29.671875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+<path style="fill:none;stroke-width:1.2;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(100%,100%,100%);stroke-opacity:1;stroke-miterlimit:4;" d="M 33.042187 17.995312 C 35.109375 21.585937 35.109375 26.00625 33.042187 29.596875 " transform="matrix(0.833333,0,0,0.833333,0,0)"/>
+</g>
+</svg>
diff --git a/music_assistant/server/providers/snapcast/manifest.json b/music_assistant/server/providers/snapcast/manifest.json
new file mode 100644 (file)
index 0000000..fcabf9a
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "type": "player",
+  "domain": "snapcast",
+  "name": "Snapcast",
+  "description": "Support for snapcast server and clients.",
+  "codeowners": ["@SantigoSotoC"],
+  "requirements": ["snapcast-mod==2.4.3", "python-ffmpeg==2.0.4"],
+  "documentation": "https://github.com/orgs/music-assistant/discussions/1165",
+  "multi_instance": false,
+  "builtin": false,
+  "load_by_default": false
+}
index 3441169c50318639545e95956d32663631a79418..2facfe7b56251da3fc90c18438cd3acccf4b0822 100644 (file)
@@ -24,8 +24,10 @@ pillow==10.1.0
 plexapi==4.15.5
 PyChromecast==13.0.7
 pycryptodome==3.18.0
+python-ffmpeg==2.0.4
 python-slugify==8.0.1
 shortuuid==1.0.11
+snapcast-mod==2.4.3
 soco==0.29.1
 tidalapi==0.7.3
 unidecode==1.3.6