Add Yamaha MusicCast provider (#2142)
authorFabian Munkes <105975993+fmunkes@users.noreply.github.com>
Tue, 29 Apr 2025 21:07:17 +0000 (23:07 +0200)
committerGitHub <noreply@github.com>
Tue, 29 Apr 2025 21:07:17 +0000 (23:07 +0200)
13 files changed:
music_assistant/constants.py
music_assistant/helpers/didl_lite.py [deleted file]
music_assistant/helpers/upnp.py [new file with mode: 0644]
music_assistant/providers/dlna/__init__.py
music_assistant/providers/musiccast/__init__.py [new file with mode: 0644]
music_assistant/providers/musiccast/avt_helpers.py [new file with mode: 0644]
music_assistant/providers/musiccast/constants.py [new file with mode: 0644]
music_assistant/providers/musiccast/icon.svg [new file with mode: 0644]
music_assistant/providers/musiccast/manifest.json [new file with mode: 0644]
music_assistant/providers/musiccast/musiccast.py [new file with mode: 0644]
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/__init__.py
requirements_all.txt

index 64d0076ac712460c94896c2505dd89ea1171441b..ac5bc2875bd077929250348b68f5570665074873 100644 (file)
@@ -587,6 +587,15 @@ CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN = ConfigEntry.from_dict(
     {**CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(), "hidden": True}
 )
 
+CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED = ConfigEntry.from_dict(
+    {
+        **CONF_ENTRY_ENABLE_ICY_METADATA.to_dict(),
+        "default_value": False,
+        "value": False,
+        "hidden": True,
+    }
+)
+
 CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
     key="preview_note",
     type=ConfigEntryType.ALERT,
diff --git a/music_assistant/helpers/didl_lite.py b/music_assistant/helpers/didl_lite.py
deleted file mode 100644 (file)
index 2e0738e..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Helper(s) to create DIDL Lite metadata for Sonos/DLNA players."""
-
-from __future__ import annotations
-
-import datetime
-from typing import TYPE_CHECKING
-from xml.sax.saxutils import escape as xmlescape
-
-from music_assistant_models.enums import MediaType
-
-from music_assistant.constants import MASS_LOGO_ONLINE
-
-if TYPE_CHECKING:
-    from music_assistant_models.player import PlayerMedia
-
-# ruff: noqa: E501
-
-
-def create_didl_metadata(media: PlayerMedia) -> str:
-    """Create DIDL metadata string from url and PlayerMedia."""
-    ext = media.uri.split(".")[-1].split("?")[0]
-    image_url = media.image_url or MASS_LOGO_ONLINE
-    if media.media_type in (MediaType.FLOW_STREAM, MediaType.RADIO) or not media.duration:
-        # flow stream, radio or other duration-less stream
-        title = media.title or media.uri
-        return (
-            '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
-            f'<item id="flowmode" parentID="0" restricted="1">'
-            f"<dc:title>{xmlescape(title)}</dc:title>"
-            f"<upnp:albumArtURI>{xmlescape(image_url)}</upnp:albumArtURI>"
-            f"<dc:queueItemId>{media.uri}</dc:queueItemId>"
-            "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
-            f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
-            f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{xmlescape(media.uri)}</res>'
-            "</item>"
-            "</DIDL-Lite>"
-        )
-    duration_str = str(datetime.timedelta(seconds=media.duration or 0)) + ".000"
-
-    return (
-        '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">'
-        f'<item id="{media.queue_item_id or xmlescape(media.uri)}" restricted="true" parentID="{media.queue_id or ""}">'
-        f"<dc:title>{xmlescape(media.title or media.uri)}</dc:title>"
-        f"<dc:creator>{xmlescape(media.artist or '')}</dc:creator>"
-        f"<upnp:album>{xmlescape(media.album or '')}</upnp:album>"
-        f"<upnp:artist>{xmlescape(media.artist or '')}</upnp:artist>"
-        f"<upnp:duration>{int(media.duration or 0)}</upnp:duration>"
-        f"<dc:queueItemId>{xmlescape(media.queue_item_id)}</dc:queueItemId>"
-        f"<dc:description>Music Assistant</dc:description>"
-        f"<upnp:albumArtURI>{xmlescape(image_url)}</upnp:albumArtURI>"
-        "<upnp:class>object.item.audioItem.musicTrack</upnp:class>"
-        f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
-        f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{xmlescape(media.uri)}</res>'
-        '<desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc>'
-        "</item>"
-        "</DIDL-Lite>"
-    )
-
-
-def create_didl_metadata_str(media: PlayerMedia) -> str:
-    """Create (xml-escaped) DIDL metadata string from url and PlayerMedia."""
-    return xmlescape(create_didl_metadata(media))
diff --git a/music_assistant/helpers/upnp.py b/music_assistant/helpers/upnp.py
new file mode 100644 (file)
index 0000000..b560f3e
--- /dev/null
@@ -0,0 +1,155 @@
+"""Helper(s) to create DIDL Lite metadata for Sonos/DLNA players."""
+
+from __future__ import annotations
+
+import datetime
+from typing import TYPE_CHECKING
+from xml.sax.saxutils import escape as xmlescape
+
+from music_assistant_models.enums import MediaType
+
+from music_assistant.constants import MASS_LOGO_ONLINE
+
+if TYPE_CHECKING:
+    from music_assistant_models.player import PlayerMedia
+
+
+# ruff: noqa: E501
+
+
+# XML
+def _get_soap_action(command: str) -> str:
+    return f"urn:schemas-upnp-org:service:AVTransport:1#{command}"
+
+
+def _get_body(command: str, arguments: str = "") -> str:
+    return (
+        f'<u:{command} xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">'
+        r"<InstanceID>0</InstanceID>"
+        f"{arguments}"
+        f"</u:{command}>"
+    )
+
+
+def _get_xml(body: str) -> str:
+    return (
+        r'<?xml version="1.0"?>'
+        r'<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">'
+        r"<s:Body>"
+        f"{body}"
+        r"</s:Body>"
+        r"</s:Envelope>"
+    )
+
+
+def get_xml_soap_play() -> tuple[str, str]:
+    """Get UPnP xml and soap for Play."""
+    command = "Play"
+    arguments = r"<Speed>1</Speed>"
+    return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+def get_xml_soap_stop() -> tuple[str, str]:
+    """Get UPnP xml and soap for Stop."""
+    command = "Stop"
+    return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_pause() -> tuple[str, str]:
+    """Get UPnP xml and soap for Pause."""
+    command = "Pause"
+    return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_next() -> tuple[str, str]:
+    """Get UPnP xml and soap for Next."""
+    command = "Next"
+    return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_previous() -> tuple[str, str]:
+    """Get UPnP xml and soap for Previous."""
+    command = "Previous"
+    return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_transport_info() -> tuple[str, str]:
+    """Get UPnP xml and soap for GetTransportInfo."""
+    command = "GetTransportInfo"
+    return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_media_info() -> tuple[str, str]:
+    """Get UPnP xml and soap for GetMediaInfo."""
+    command = "GetMediaInfo"
+    return _get_xml(_get_body(command)), _get_soap_action(command)
+
+
+def get_xml_soap_set_url(player_media: PlayerMedia) -> tuple[str, str]:
+    """Get UPnP xml and soap for SetAVTransportURI."""
+    metadata = create_didl_metadata_str(player_media)
+    command = "SetAVTransportURI"
+    arguments = (
+        f"<CurrentURI>{player_media.uri}</CurrentURI>"
+        "<CurrentURIMetaData>"
+        f"{metadata}"
+        "</CurrentURIMetaData>"
+    )
+    return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+def get_xml_soap_set_next_url(player_media: PlayerMedia) -> tuple[str, str]:
+    """Get UPnP xml and soap for SetNextAVTransportURI."""
+    metadata = create_didl_metadata_str(player_media)
+    command = "SetNextAVTransportURI"
+    arguments = (
+        f"<NextURI>{player_media.uri}</NextURI><NextURIMetaData>{metadata}</NextURIMetaData>"
+    )
+    return _get_xml(_get_body(command, arguments)), _get_soap_action(command)
+
+
+# DIDL-LITE
+def create_didl_metadata(media: PlayerMedia) -> str:
+    """Create DIDL metadata string from url and PlayerMedia."""
+    ext = media.uri.split(".")[-1].split("?")[0]
+    image_url = media.image_url or MASS_LOGO_ONLINE
+    if media.media_type in (MediaType.FLOW_STREAM, MediaType.RADIO) or not media.duration:
+        # flow stream, radio or other duration-less stream
+        title = media.title or media.uri
+        return (
+            '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">'
+            f'<item id="flowmode" parentID="0" restricted="1">'
+            f"<dc:title>{xmlescape(title)}</dc:title>"
+            f"<upnp:albumArtURI>{xmlescape(image_url)}</upnp:albumArtURI>"
+            f"<dc:queueItemId>{media.uri}</dc:queueItemId>"
+            "<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>"
+            f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
+            f'<res protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{xmlescape(media.uri)}</res>'
+            "</item>"
+            "</DIDL-Lite>"
+        )
+    duration_str = str(datetime.timedelta(seconds=media.duration or 0)) + ".000"
+
+    return (
+        '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/">'
+        f'<item id="{media.queue_item_id or xmlescape(media.uri)}" restricted="true" parentID="{media.queue_id or ""}">'
+        f"<dc:title>{xmlescape(media.title or media.uri)}</dc:title>"
+        f"<dc:creator>{xmlescape(media.artist or '')}</dc:creator>"
+        f"<upnp:album>{xmlescape(media.album or '')}</upnp:album>"
+        f"<upnp:artist>{xmlescape(media.artist or '')}</upnp:artist>"
+        f"<upnp:duration>{int(media.duration or 0)}</upnp:duration>"
+        f"<dc:queueItemId>{xmlescape(media.queue_item_id)}</dc:queueItemId>"
+        f"<dc:description>Music Assistant</dc:description>"
+        f"<upnp:albumArtURI>{xmlescape(image_url)}</upnp:albumArtURI>"
+        "<upnp:class>object.item.audioItem.musicTrack</upnp:class>"
+        f"<upnp:mimeType>audio/{ext}</upnp:mimeType>"
+        f'<res duration="{duration_str}" protocolInfo="http-get:*:audio/{ext}:DLNA.ORG_PN={ext.upper()};DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000">{xmlescape(media.uri)}</res>'
+        '<desc id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">RINCON_AssociatedZPUDN</desc>'
+        "</item>"
+        "</DIDL-Lite>"
+    )
+
+
+def create_didl_metadata_str(media: PlayerMedia) -> str:
+    """Create (xml-escaped) DIDL metadata string from url and PlayerMedia."""
+    return xmlescape(create_didl_metadata(media))
index c8ed06d1073554ec251b8d195c5e14c89a470490..ca9d55267f84acf77cbe58aa3aab108d7a3a0864 100644 (file)
@@ -36,7 +36,7 @@ from music_assistant.constants import (
     VERBOSE_LOG_LEVEL,
     create_sample_rates_config_entry,
 )
-from music_assistant.helpers.didl_lite import create_didl_metadata
+from music_assistant.helpers.upnp import create_didl_metadata
 from music_assistant.helpers.util import TaskManager
 from music_assistant.models.player_provider import PlayerProvider
 
diff --git a/music_assistant/providers/musiccast/__init__.py b/music_assistant/providers/musiccast/__init__.py
new file mode 100644 (file)
index 0000000..47acd9e
--- /dev/null
@@ -0,0 +1,826 @@
+"""MusicCast for MusicAssistant."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from aiohttp.client_exceptions import ServerDisconnectedError
+from aiomusiccast.exceptions import MusicCastGroupException
+from aiomusiccast.musiccast_device import MusicCastDevice
+from aiomusiccast.pyamaha import MusicCastConnectionException
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import (
+    ConfigEntryType,
+    PlayerFeature,
+    PlayerState,
+    PlayerType,
+    ProviderFeature,
+)
+from music_assistant_models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
+from zeroconf import ServiceStateChange
+
+from music_assistant.constants import VERBOSE_LOG_LEVEL
+from music_assistant.models.player_provider import PlayerProvider
+from music_assistant.providers.musiccast.avt_helpers import (
+    avt_get_media_info,
+    avt_next,
+    avt_pause,
+    avt_play,
+    avt_previous,
+    avt_set_url,
+    avt_stop,
+    search_xml,
+)
+from music_assistant.providers.sonos.helpers import get_primary_ip_address
+
+from .constants import (
+    CONF_PLAYER_SWITCH_SOURCE_NON_NET,
+    CONF_PLAYER_TURN_OFF_ON_LEAVE,
+    MC_CONTROL_SOURCE_IDS,
+    MC_DEVICE_INFO_ENDPOINT,
+    MC_DEVICE_UPNP_ENDPOINT,
+    MC_DEVICE_UPNP_PORT,
+    MC_NETUSB_SOURCE_IDS,
+    MC_PASSIVE_SOURCE_IDS,
+    MC_POLL_INTERVAL,
+    MC_SOURCE_MAIN_SYNC,
+    MC_SOURCE_MC_LINK,
+    PLAYER_CONFIG_ENTRIES,
+    PLAYER_MAP_ZONE_SWITCH,
+    PLAYER_ZONE_SPLITTER,
+)
+from .musiccast import (
+    MusicCastController,
+    MusicCastPhysicalDevice,
+    MusicCastPlayerState,
+    MusicCastZoneDevice,
+)
+
+if TYPE_CHECKING:
+    from music_assistant_models.config_entries import (
+        ConfigValueType,
+        ProviderConfig,
+    )
+    from music_assistant_models.provider import ProviderManifest
+    from zeroconf.asyncio import AsyncServiceInfo
+
+    from music_assistant.mass import MusicAssistant
+    from music_assistant.models import ProviderInstanceType
+
+
+async def setup(
+    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
+) -> ProviderInstanceType:
+    """Initialize provider(instance) with given configuration."""
+    return MusicCast(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, ...]:
+    """
+    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 ()
+
+
+@dataclass(kw_only=True)
+class MusicCastPlayer:
+    """MusicCastPlayer.
+
+    Helper class to store MA player alongside physical device.
+    """
+
+    device_id: str  # device_id without ZONE_SPLITTER zone
+    player_main: Player | None = None  # mass player
+    player_zone2: Player | None = None  # mass player
+    # I can only test up to zone 2
+    player_zone3: Player | None = None  # mass player
+    player_zone4: Player | None = None  # mass player
+
+    # log allowed sources for a device with multiple sources once. see "_handle_zone_grouping"
+    _log_allowed_sources: bool = True
+
+    physical_device: MusicCastPhysicalDevice
+
+    def get_player(self, zone: str) -> Player | None:
+        """Get Player by zone name."""
+        match zone:
+            case "main":
+                return self.player_main
+            case "zone2":
+                return self.player_zone2
+            case "zone3":
+                return self.player_zone3
+            case "zone4":
+                return self.player_zone4
+        raise RuntimeError(f"Zone {zone} is unknown.")
+
+    def get_all_players(self) -> list[Player]:
+        """Get all players."""
+        assert self.player_main is not None  # we always have main
+        players = [self.player_main]
+        if self.player_zone2 is not None:
+            players.append(self.player_zone2)
+        if self.player_zone3 is not None:
+            players.append(self.player_zone3)
+        if self.player_zone4 is not None:
+            players.append(self.player_zone4)
+        return players
+
+
+@dataclass(kw_only=True)
+class UpnpUpdateHelper:
+    """UpnpUpdateHelper.
+
+    See _update_player_attributes.
+    """
+
+    last_poll: float  # time.time
+    controlled_by_mass: bool
+    current_uri: str | None
+
+
+class MusicCast(PlayerProvider):
+    """MusicCast."""
+
+    musiccast_players: dict[str, MusicCastPlayer] = {}
+
+    # poll upnp playback information, but not too often. see "_update_player_attributes"
+    # player_id: UpnpUpdateHelper
+    upnp_update_helper: dict[str, UpnpUpdateHelper] = {}
+
+    # str here is the device id, NOT the player_id
+    update_player_locks: dict[str, asyncio.Lock] = {}
+
+    @property
+    def supported_features(self) -> set[ProviderFeature]:
+        """Return the features supported by this Provider."""
+        return {ProviderFeature.SYNC_PLAYERS}
+
+    async def handle_async_init(self) -> None:
+        """Async init."""
+        self.mc_controller = MusicCastController(logger=self.logger)
+        # aiomusiccast logs all fetch requests after udp message as debug.
+        # same approach as in upnp
+        if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
+            logging.getLogger("aiomusiccast").setLevel(logging.DEBUG)
+        else:
+            logging.getLogger("aiomusiccast").setLevel(self.logger.level + 10)
+
+    async def unload(self, is_removed: bool = False) -> None:
+        """Call on unload."""
+        for mc_player in self.musiccast_players.values():
+            mc_player.physical_device.remove()
+
+    async def get_player_config_entries(
+        self,
+        player_id: str,
+    ) -> tuple[ConfigEntry, ...]:
+        """Return all (provider/player specific) Config Entries for the given player (if any)."""
+        base_entries = await super().get_player_config_entries(player_id)
+        zone_entries: tuple[ConfigEntry, ...] = ()
+        if zone_player := self._get_zone_player(player_id):
+            if len(zone_player.physical_device.zone_devices) > 1:
+                zone_entries = (
+                    ConfigEntry(
+                        key=CONF_PLAYER_SWITCH_SOURCE_NON_NET,
+                        type=ConfigEntryType.STRING,
+                        label="Switch to this non-net source on group leave.",
+                        default_value=PLAYER_MAP_ZONE_SWITCH[zone_player.zone_name],
+                        description="Switch to this non-net source on group leave. "
+                        " This must be the source_id.",
+                    ),
+                    ConfigEntry(
+                        key=CONF_PLAYER_TURN_OFF_ON_LEAVE,
+                        type=ConfigEntryType.BOOLEAN,
+                        label="Turn off zone after group is left.",
+                        default_value=False,
+                        description="Turn off zone after group is left.",
+                    ),
+                )
+
+        return base_entries + zone_entries + PLAYER_CONFIG_ENTRIES
+
+    def _get_zone_player(self, player_id: str) -> MusicCastZoneDevice | None:
+        """Get music cast zone entity based on player id."""
+        device_id, zone = player_id.split(PLAYER_ZONE_SPLITTER)
+        mc_player = self.musiccast_players.get(device_id)
+        if mc_player is None:
+            return None
+        return mc_player.physical_device.zone_devices.get(zone)
+
+    async def _set_player_unavailable(self, player_id: str) -> None:
+        """Set a player unavailable, and remove it from the MC group.
+
+        Update all clients.
+        """
+        device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER)
+        mc_player = self.musiccast_players.get(device_id)
+        if mc_player is None:
+            return
+        mc_player.physical_device.remove()
+        for player in mc_player.get_all_players():
+            # disable zones as well.
+            player.available = False
+            await self.mass.players.register_or_update(player)
+
+    async def _cmd_run(
+        self, player_id: str, fun: Callable[..., Coroutine[Any, Any, None]], *args: Any
+    ) -> None:
+        """Help function for all player cmds."""
+        try:
+            await fun(*args)
+        except MusicCastConnectionException:
+            await self._set_player_unavailable(player_id)
+            self.logger.debug("Player became unavailable.")
+        except MusicCastGroupException:
+            # can happen, user shall try again.
+            ...
+
+    def _get_player_id_from_mc_zone_player(self, zone_player: MusicCastZoneDevice) -> str:
+        device_id = zone_player.physical_device.device.data.device_id
+        assert device_id is not None
+        return f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_player.zone_name}"
+
+    async def cmd_stop(self, player_id: str) -> None:
+        """Send STOP command to given player."""
+        if zone_player := self._get_zone_player(player_id):
+            upnp_update_helper = self.upnp_update_helper.get(player_id)
+            if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+                await avt_stop(self.mass.http_session, zone_player.physical_device)
+            else:
+                await self._cmd_run(player_id, zone_player.stop)
+
+    async def cmd_play(self, player_id: str) -> None:
+        """Send PLAY command to given player."""
+        if zone_player := self._get_zone_player(player_id):
+            upnp_update_helper = self.upnp_update_helper.get(player_id)
+            if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+                await avt_play(self.mass.http_session, zone_player.physical_device)
+            else:
+                await self._cmd_run(player_id, zone_player.play)
+
+    async def cmd_pause(self, player_id: str) -> None:
+        """Send PAUSE command to given player."""
+        if zone_player := self._get_zone_player(player_id):
+            upnp_update_helper = self.upnp_update_helper.get(player_id)
+            if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+                await avt_pause(self.mass.http_session, zone_player.physical_device)
+            else:
+                await self._cmd_run(player_id, zone_player.pause)
+
+    async def cmd_next(self, player_id: str) -> None:
+        """Send NEXT."""
+        if zone_player := self._get_zone_player(player_id):
+            upnp_update_helper = self.upnp_update_helper.get(player_id)
+            if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+                await avt_next(self.mass.http_session, zone_player.physical_device)
+            else:
+                await self._cmd_run(player_id, zone_player.next_track)
+
+    async def cmd_previous(self, player_id: str) -> None:
+        """Send PREVIOUS."""
+        if zone_player := self._get_zone_player(player_id):
+            upnp_update_helper = self.upnp_update_helper.get(player_id)
+            if upnp_update_helper is not None and upnp_update_helper.controlled_by_mass:
+                await avt_previous(self.mass.http_session, zone_player.physical_device)
+            else:
+                await self._cmd_run(player_id, zone_player.previous_track)
+
+    async def cmd_volume_set(self, player_id: str, volume_level: int) -> None:
+        """Send VOLUME_SET command to given player."""
+        if zone_player := self._get_zone_player(player_id):
+            await self._cmd_run(player_id, zone_player.volume_set, volume_level)
+
+    async def cmd_volume_mute(self, player_id: str, muted: bool) -> None:
+        """Send VOLUME MUTE command to given player."""
+        if zone_player := self._get_zone_player(player_id):
+            await self._cmd_run(player_id, zone_player.volume_mute, muted)
+
+    async def cmd_power(self, player_id: str, powered: bool) -> None:
+        """Send POWER command to given player."""
+        if zone_player := self._get_zone_player(player_id):
+            if powered:
+                await self._cmd_run(player_id, zone_player.turn_on)
+            else:
+                await self._cmd_run(player_id, zone_player.turn_off)
+
+    async def cmd_group(self, player_id: str, target_player: str) -> None:
+        """Handle GROUP command for given player."""
+        await self.cmd_group_many(target_player=target_player, child_player_ids=[player_id])
+
+    async def cmd_ungroup(self, player_id: str) -> None:
+        """Handle UNGROUP command for given player."""
+        if zone_player := self._get_zone_player(player_id):
+            if zone_player.zone_name.startswith("zone"):
+                # We are are zone.
+                # We do not leave an MC group, but just change our source.
+                await self._handle_zone_grouping(zone_player)
+                return
+            await self._cmd_run(player_id, zone_player.unjoin_player)
+
+    def _get_allowed_sources_zone_switch(self, zone_player: MusicCastZoneDevice) -> set[str]:
+        """Return non net sources for a zone player."""
+        assert zone_player.zone_data is not None, "zone data missing"
+        _input_sources: set[str] = set(zone_player.zone_data.input_list)
+        _net_sources = set(MC_NETUSB_SOURCE_IDS)
+        _net_sources.add(MC_SOURCE_MC_LINK)  # mc grouping source
+        return _input_sources.difference(_net_sources)
+
+    async def _handle_zone_grouping(self, zone_player: MusicCastZoneDevice) -> None:
+        """Handle zone grouping.
+
+        If a device has multiple zones, only a single zone can be net controlled.
+        If another zone wants to join the group, the current net zone has to switch
+        its input to a non-net one and optionally turn off.
+        """
+        player_id = self._get_player_id_from_mc_zone_player(zone_player)
+        assert player_id is not None  # for TYPE_CHECKING
+        _source = str(
+            await self.mass.config.get_player_config_value(
+                player_id, CONF_PLAYER_SWITCH_SOURCE_NON_NET
+            )
+        )
+        # verify that this source actually exists and is non net
+        _allowed_sources = self._get_allowed_sources_zone_switch(zone_player)
+        if _source not in _allowed_sources:
+            mass_player = self.mass.players.get(player_id)
+            assert mass_player is not None
+            msg = (
+                f"The switch source you specified for {mass_player.name} is not allowed. "
+                f"The source must be any of: {', '.join(sorted(_allowed_sources))} "
+                "Will use the first available source."
+            )
+            self.logger.error(msg)
+            _source = _allowed_sources.pop()
+
+        await self._cmd_run(player_id, zone_player.select_source, _source)
+        _turn_off = bool(
+            await self.mass.config.get_player_config_value(player_id, CONF_PLAYER_TURN_OFF_ON_LEAVE)
+        )
+        if _turn_off:
+            await asyncio.sleep(2)
+            await self._cmd_run(player_id, zone_player.turn_off)
+
+    async def cmd_group_many(self, target_player: str, child_player_ids: list[str]) -> None:
+        """Create temporary sync group by joining given players to target player."""
+        device_id, zone_server = target_player.split(PLAYER_ZONE_SPLITTER)
+        server = self._get_zone_player(target_player)
+        if server is None:
+            return
+        children: set[MusicCastZoneDevice] = set()
+        children_zones: list[MusicCastZoneDevice] = []
+        for child_id in child_player_ids:
+            if child := self._get_zone_player(child_id):
+                _other_zone_mc: MusicCastZoneDevice | None = None
+                for x in child.other_zones:
+                    if x.is_netusb:
+                        _other_zone_mc = x
+                if _other_zone_mc and _other_zone_mc != child:
+                    # of the same device, we use main_sync as input
+                    if _other_zone_mc.zone_name == "main":
+                        children_zones.append(child)
+                    else:
+                        self.logger.warning(
+                            "It is impossible to join as a normal zone to another zone of the same "
+                            "device. Only joining to main is possible. Please refer to the docs."
+                        )
+                else:
+                    children.add(child)
+
+        for child in children_zones:
+            child_player_id = self._get_player_id_from_mc_zone_player(child)
+            if child.state == MusicCastPlayerState.OFF:
+                await self._cmd_run(child_player_id, child.turn_on)
+            await self.select_source(child_player_id, MC_SOURCE_MAIN_SYNC)
+        if not children:
+            return
+
+        await self._cmd_run(target_player, server.join_players, list(children))
+
+    async def cmd_ungroup_member(self, player_id: str, target_player: str) -> None:
+        """Handle UNGROUP command for given player."""
+        await self.cmd_ungroup(player_id)
+
+    async def select_source(self, player_id: str, source: str) -> None:
+        """Handle SELECT SOURCE command on given player."""
+        if zone_player := self._get_zone_player(player_id):
+            await self._cmd_run(player_id, zone_player.select_source, source)
+
+    async def play_media(
+        self,
+        player_id: str,
+        media: PlayerMedia,
+    ) -> None:
+        """Handle PLAY MEDIA on given player."""
+        if zone_player := self._get_zone_player(player_id):
+            if len(zone_player.physical_device.zone_devices) > 1:
+                # zone handling
+                # only a single zone may have netusb capability
+                for zone_name, dev in zone_player.physical_device.zone_devices.items():
+                    if zone_name == zone_player.zone_name:
+                        continue
+                    if dev.is_netusb:
+                        await self._handle_zone_grouping(dev)
+            device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER)
+            lock = self.update_player_locks.get(device_id)
+            assert lock is not None  # for type checking
+            async with lock:
+                # just in case
+                if zone_player.source_id != "server":
+                    await self.select_source(player_id, "server")
+                await avt_set_url(
+                    self.mass.http_session, zone_player.physical_device, player_media=media
+                )
+                await avt_play(self.mass.http_session, zone_player.physical_device)
+
+                self.upnp_update_helper[player_id] = UpnpUpdateHelper(
+                    last_poll=time.time(),
+                    controlled_by_mass=True,
+                    current_uri=media.uri,
+                )
+
+                if ma_player := self.mass.players.get(player_id):
+                    ma_player.current_media = media
+                    await self.mass.players.register_or_update(ma_player)
+
+    async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
+        """Enqueue next."""
+        if zone_player := self._get_zone_player(player_id):
+            await avt_set_url(
+                self.mass.http_session,
+                zone_player.physical_device,
+                player_media=media,
+                enqueue=True,
+            )
+
+    async def poll_player(self, player_id: str, fetch: bool = True) -> None:
+        """Poll player for state updates, only main zone is polled."""
+        # we only poll for main, as we get zones alongside
+        device_id, _ = player_id.split(PLAYER_ZONE_SPLITTER)
+        mc_player = self.musiccast_players.get(device_id)
+        if mc_player is None:
+            return
+
+        lock = self.update_player_locks.get(device_id)
+        assert lock is not None  # for type checking
+        if lock.locked():
+            # we are called roughly every 1s when playing on udp callback, so just discard.
+            return
+        async with lock:
+            if fetch:  # non udp "explicit polling case"
+                try:
+                    await mc_player.physical_device.fetch()
+                except (MusicCastConnectionException, MusicCastGroupException):
+                    await self._set_player_unavailable(player_id)
+                    return
+                except ServerDisconnectedError:
+                    return
+
+            for player in mc_player.get_all_players():
+                _, zone = player.player_id.split(PLAYER_ZONE_SPLITTER)
+                zone_device = mc_player.physical_device.zone_devices.get(zone)
+                if zone_device is None:
+                    continue
+                await self._update_player_attributes(player, zone_device)
+                player.available = True
+                await self.mass.players.register_or_update(player)
+
+    async def on_mdns_service_state_change(
+        self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
+    ) -> None:
+        """Discovery via mdns."""
+        if state_change == ServiceStateChange.Removed:
+            # Wait for connection to fail, same as sonos.
+            return
+        if info is None:
+            return
+        device_ip = get_primary_ip_address(info)
+        if device_ip is None:
+            return
+        device_info = await self.mass.http_session.get(
+            f"http://{device_ip}/{MC_DEVICE_INFO_ENDPOINT}"
+        )
+        if device_info.status == 404:
+            return
+        device_info_json = await device_info.json()
+        device_id = device_info_json.get("device_id")
+        if device_id is None:
+            return
+        description_url = f"http://{device_ip}:{MC_DEVICE_UPNP_PORT}/{MC_DEVICE_UPNP_ENDPOINT}"
+
+        _check = await self.mass.http_session.get(description_url)
+        if _check.status == 404:
+            self.logger.debug("Missing description url for Yamaha device at %s", device_ip)
+            return
+        await self._device_discovered(
+            device_id=device_id, device_ip=device_ip, description_url=description_url
+        )
+
+    async def _device_discovered(
+        self, device_id: str, device_ip: str, description_url: str
+    ) -> None:
+        """Handle discovered MusicCast player."""
+        # verify that this is a MusicCast player
+        check: bool = await MusicCastDevice.check_yamaha_ssdp(
+            description_url, self.mass.http_session
+        )
+        if not check:
+            return
+
+        mc_player_known = self.musiccast_players.get(device_id)
+        if (
+            mc_player_known is not None
+            and mc_player_known.player_main is not None
+            and (
+                mc_player_known.physical_device.device.device.upnp_description == description_url
+                and mc_player_known.player_main.available
+            )
+        ):
+            # nothing to do, device is already connected
+            return
+        else:
+            # new or updated player detected
+            physical_device = MusicCastPhysicalDevice(
+                device=MusicCastDevice(
+                    client=self.mass.http_session,
+                    ip=device_ip,
+                    upnp_description=description_url,
+                ),
+                controller=self.mc_controller,
+            )
+            self.update_player_locks[device_id] = asyncio.Lock()
+            success = await physical_device.async_init()  # fetch + polling
+            if not success:
+                self.logger.debug(
+                    "Had trouble setting up device at %s. Will be retried on next discovery.",
+                    device_ip,
+                )
+                return
+            physical_device.register_callback(self._non_async_udp_callback)
+            await self._register_player(physical_device, device_id)
+
+    async def _register_player(
+        self, physical_device: MusicCastPhysicalDevice, device_id: str
+    ) -> None:
+        """Register player including zones."""
+        device_info = DeviceInfo(
+            manufacturer="Yamaha Corporation",
+            model=physical_device.device.data.model_name or "unknown model",
+            software_version=physical_device.device.data.system_version or "unknown version",
+        )
+
+        def get_player(zone_name: str, player_name: str) -> Player:
+            # player features
+            # TODO: There is seek in the upnp desc
+            # http://{ip}:49154/AVTransport/desc.xml
+            supported_features: set[PlayerFeature] = {
+                PlayerFeature.VOLUME_SET,
+                PlayerFeature.VOLUME_MUTE,
+                PlayerFeature.PAUSE,
+                PlayerFeature.POWER,
+                PlayerFeature.SELECT_SOURCE,
+                PlayerFeature.SET_MEMBERS,
+                PlayerFeature.NEXT_PREVIOUS,
+                PlayerFeature.ENQUEUE,
+                PlayerFeature.GAPLESS_PLAYBACK,
+            }
+
+            return Player(
+                player_id=f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_name}",
+                provider=self.instance_id,
+                type=PlayerType.PLAYER,
+                name=player_name,
+                available=True,
+                device_info=device_info,
+                needs_poll=zone_name == "main",
+                poll_interval=MC_POLL_INTERVAL,
+                supported_features=supported_features,
+            )
+
+        main_device = physical_device.zone_devices.get("main")
+        if (
+            main_device is None
+            or main_device.zone_data is None
+            or main_device.zone_data.name is None
+        ):
+            return
+        musiccast_player = MusicCastPlayer(
+            device_id=device_id,
+            physical_device=physical_device,
+        )
+
+        for zone_name, zone_device in physical_device.zone_devices.items():
+            if zone_device.zone_data is None or zone_device.zone_data.name is None:
+                continue
+            player = get_player(zone_name, zone_device.zone_data.name)
+            setattr(musiccast_player, f"player_{zone_device.zone_name}", player)
+            await self._update_player_attributes(player, zone_device)
+            await self.mass.players.register_or_update(player)
+
+        if musiccast_player.player_zone2 is not None and musiccast_player._log_allowed_sources:
+            musiccast_player._log_allowed_sources = False
+            player_main = musiccast_player.player_main
+            assert player_main is not None
+            main_zone_device = musiccast_player.physical_device.zone_devices.get("main")
+            assert main_zone_device is not None
+            _allowed_sources = self._get_allowed_sources_zone_switch(main_zone_device)
+            self.logger.info(
+                f"The player {player_main.name} has multiple zones. "
+                "Please use the player config to configure a non-net source  for grouping. "
+                f"Allowed values are: {', '.join(_allowed_sources)}. See docs."
+            )
+
+        self.musiccast_players[device_id] = musiccast_player
+
+    async def _update_player_attributes(self, player: Player, device: MusicCastZoneDevice) -> None:
+        # ruff: noqa: PLR0915
+        zone_data = device.zone_data
+        if zone_data is None:
+            return
+
+        player.name = zone_data.name or "UNKNOWN NAME"
+        player.powered = zone_data.power == "on"
+
+        player.volume_level = int(
+            zone_data.current_volume / (zone_data.max_volume - zone_data.min_volume) * 100
+        )
+        player.volume_muted = zone_data.mute
+
+        # STATE
+
+        match device.state:
+            case MusicCastPlayerState.PAUSED:
+                player.state = PlayerState.PAUSED
+            case MusicCastPlayerState.PLAYING:
+                player.state = PlayerState.PLAYING
+            case MusicCastPlayerState.IDLE | MusicCastPlayerState.OFF:
+                player.state = PlayerState.IDLE
+        player.elapsed_time = device.media_position
+        player.elapsed_time_last_updated = device.media_position_updated_at
+
+        # SOURCES
+        source_list: list[PlayerSource] = []
+        for source_id, source_name in device.source_mapping.items():
+            control = source_id in MC_CONTROL_SOURCE_IDS
+            passive = source_id in MC_PASSIVE_SOURCE_IDS
+            source_list.append(
+                PlayerSource(
+                    id=source_id,
+                    name=source_name,
+                    passive=passive,
+                    can_play_pause=control,
+                    can_seek=False,
+                    can_next_previous=control,
+                )
+            )
+        player.source_list.set(source_list)
+
+        # UPDATE UPNP HELPER
+        update_helper = self.upnp_update_helper.get(player.player_id)
+        now = time.time()
+        if update_helper is None or now - update_helper.last_poll > 5:
+            # Let's not do this too often
+            # Note: The devices always return the last UPnP xmls, even if
+            # currently another source/ playback method is used
+            try:
+                _xml_media_info = await avt_get_media_info(
+                    self.mass.http_session, device.physical_device
+                )
+            except ServerDisconnectedError:
+                return
+            _player_current_url = search_xml(_xml_media_info, "CurrentURI")
+
+            # controlled by mass is only True, if we are directly controlled
+            # i.e. we are not a group member.
+            # the device's source id is server, if controlled by upnp, but also, if the internal
+            # dlna function of the device are used. As a fallback, we then
+            # use the item's title. This can only fail, if our current and next item
+            # has the same name as the external.
+            controlled_by_mass = False
+            if _player_current_url is not None:
+                controlled_by_mass = (
+                    player.player_id in _player_current_url
+                    and self.mass.streams.base_url in _player_current_url
+                    and device.source_id == "server"
+                )
+
+            update_helper = UpnpUpdateHelper(
+                last_poll=now,
+                controlled_by_mass=controlled_by_mass,
+                current_uri=_player_current_url,
+            )
+
+            self.upnp_update_helper[player.player_id] = update_helper
+
+        # UPDATE PLAYBACK INFORMATION
+        # Note to self:
+        # player.current_media tells queue controller what is playing
+        # and player.set_current_media is the helper function
+        # do not access the queue controller to gain playback information here
+        if update_helper.current_uri is not None and update_helper.controlled_by_mass:
+            player.set_current_media(uri=update_helper.current_uri)
+        elif device.is_client:
+            _server = device.group_server
+            _server_id = self._get_player_id_from_mc_zone_player(_server)
+            _server_update_helper = self.upnp_update_helper.get(_server_id)
+            if (
+                _server_update_helper is not None
+                and _server_update_helper.current_uri is not None
+                and _server_update_helper.controlled_by_mass
+            ):
+                player.set_current_media(
+                    uri=_server_update_helper.current_uri,
+                )
+            else:
+                player.set_current_media(
+                    uri=f"{_server_id}_{_server.source_id}",
+                    title=_server.media_title,
+                    artist=_server.media_artist,
+                    album=_server.media_album_name,
+                    image_url=_server.media_image_url,
+                )
+        else:
+            player.set_current_media(
+                uri=f"{player.player_id}_{device.source_id}",
+                title=device.media_title,
+                artist=device.media_artist,
+                album=device.media_album_name,
+                image_url=device.media_image_url,
+            )
+
+        # SOURCE
+        player.active_source = None  # means the player controller will figure it out
+        if not device.is_client and not update_helper.controlled_by_mass:
+            player.active_source = device.source_id
+        elif device.is_client:
+            _server = device.group_server
+            _server_id = self._get_player_id_from_mc_zone_player(_server)
+            if _server_update_helper := self.upnp_update_helper.get(_server_id):
+                player.active_source = (
+                    device.source_id if not _server_update_helper.controlled_by_mass else None
+                )
+
+        # GROUPING
+        # A zone cannot be synced to another zone or main of the same device.
+        # Additionally, a zone can only be synced, if main is currently not using any netusb
+        # function.
+        # For a Zone which will be synced to main, grouping emits a "main_sync" instead
+        # of a mc link. The other way round, we log a warning.
+        player.can_group_with = {self.instance_id}
+
+        if len(device.musiccast_group) == 1:
+            if device.musiccast_group[0] == device:
+                # we are in a group with ourselves.
+                player.group_childs.clear()
+                player.synced_to = None
+                player.active_group = None
+
+        elif not device.is_client and not device.is_server:
+            player.group_childs.clear()
+            player.synced_to = None
+            player.active_group = None
+
+        elif device.is_client:
+            _synced_to_id = self._get_player_id_from_mc_zone_player(device.group_server)
+            player.group_childs.clear()
+            player.synced_to = _synced_to_id
+            player.active_group = _synced_to_id
+
+        elif device.is_server:
+            player.group_childs.set(
+                [self._get_player_id_from_mc_zone_player(x) for x in device.musiccast_group]
+            )
+            player.synced_to = None
+            player.active_group = None
+
+    def _non_async_udp_callback(self, mc_physical_device: MusicCastPhysicalDevice) -> None:
+        """Update callback.
+
+        This is called if there are new UDP updates. Unfortunately, aiomusiccast
+        only allows a sync callback, so we schedule an async task.
+        """
+        mc_player: MusicCastPlayer | None = None
+        for mc_player in self.musiccast_players.values():
+            if mc_player.physical_device == mc_physical_device:
+                break
+        assert mc_player is not None  # for type checking
+        if mc_player.player_main is None:
+            return
+        main_player_id = mc_player.player_main.player_id
+        # disable another fetch, these attributes were set via UDP
+        self.mass.loop.create_task(self.poll_player(main_player_id, False))
diff --git a/music_assistant/providers/musiccast/avt_helpers.py b/music_assistant/providers/musiccast/avt_helpers.py
new file mode 100644 (file)
index 0000000..c71602d
--- /dev/null
@@ -0,0 +1,148 @@
+"""Helpers to make an UPnP request."""
+
+import aiohttp
+from music_assistant_models.player import PlayerMedia
+
+from music_assistant.helpers.upnp import (
+    get_xml_soap_media_info,
+    get_xml_soap_next,
+    get_xml_soap_pause,
+    get_xml_soap_play,
+    get_xml_soap_previous,
+    get_xml_soap_set_next_url,
+    get_xml_soap_set_url,
+    get_xml_soap_stop,
+    get_xml_soap_transport_info,
+)
+from music_assistant.providers.musiccast.constants import (
+    MC_DEVICE_UPNP_CTRL_ENDPOINT,
+    MC_DEVICE_UPNP_PORT,
+)
+from music_assistant.providers.musiccast.musiccast import MusicCastPhysicalDevice
+
+
+def get_headers(xml: str, soap_action: str) -> dict[str, str]:
+    """Get headers for MusicCast."""
+    return {
+        "Content-Type": 'text/xml; charset="utf-8"',
+        "SOAPACTION": f'"{soap_action}"',
+        "Accept": "*/*",
+        "User-Agent": "MusicCast/6.00 (Android)",
+        "Content-Length": str(len(xml)),
+    }
+
+
+def get_upnp_ctrl_url(physical_device: MusicCastPhysicalDevice) -> str:
+    """Get UPNP control URL."""
+    return f"http://{physical_device.device.device.ip}:{MC_DEVICE_UPNP_PORT}/{MC_DEVICE_UPNP_CTRL_ENDPOINT}"
+
+
+async def avt_play(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+) -> None:
+    """Play."""
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    xml, soap_action = get_xml_soap_play()
+    headers = get_headers(xml, soap_action)
+    await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_stop(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+) -> None:
+    """Play."""
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    xml, soap_action = get_xml_soap_stop()
+    headers = get_headers(xml, soap_action)
+    await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_pause(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+) -> None:
+    """Play."""
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    xml, soap_action = get_xml_soap_pause()
+    headers = get_headers(xml, soap_action)
+    await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_next(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+) -> None:
+    """Play."""
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    xml, soap_action = get_xml_soap_next()
+    headers = get_headers(xml, soap_action)
+    await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_previous(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+) -> None:
+    """Play."""
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    xml, soap_action = get_xml_soap_previous()
+    headers = get_headers(xml, soap_action)
+    await client.post(ctrl_url, headers=headers, data=xml)
+
+
+async def avt_get_media_info(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+) -> str:
+    """Get Media Info."""
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    xml, soap_action = get_xml_soap_media_info()
+    headers = get_headers(xml, soap_action)
+    response = await client.post(ctrl_url, headers=headers, data=xml)
+    response_text = await response.read()
+    return response_text.decode()
+
+
+async def avt_get_transport_info(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+) -> str:
+    """Get Media Info."""
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    xml, soap_action = get_xml_soap_transport_info()
+    headers = get_headers(xml, soap_action)
+    response = await client.post(ctrl_url, headers=headers, data=xml)
+    response_text = await response.read()
+    return response_text.decode()
+
+
+async def avt_set_url(
+    client: aiohttp.ClientSession,
+    physical_device: MusicCastPhysicalDevice,
+    player_media: PlayerMedia,
+    enqueue: bool = False,
+) -> None:
+    """Set Url.
+
+    If device is playing, this will just continue with new media.
+    """
+    ctrl_url = get_upnp_ctrl_url(physical_device)
+    if enqueue:
+        xml, soap_action = get_xml_soap_set_next_url(player_media)
+    else:
+        xml, soap_action = get_xml_soap_set_url(player_media)
+    headers = get_headers(xml, soap_action)
+    await client.post(ctrl_url, headers=headers, data=xml)
+
+
+def search_xml(xml: str, tag: str) -> str | None:
+    """Search single line xml for these tags."""
+    start_str = f"<{tag}>"
+    end_str = f"</{tag}>"
+    start_int = xml.find(start_str)
+    end_int = xml.find(end_str)
+    if start_int == -1 or end_int == -1:
+        return None
+    return xml[start_int + len(start_str) : end_int]
diff --git a/music_assistant/providers/musiccast/constants.py b/music_assistant/providers/musiccast/constants.py
new file mode 100644 (file)
index 0000000..911ae5c
--- /dev/null
@@ -0,0 +1,80 @@
+"""Constants for the MusicCast provider."""
+
+from music_assistant.constants import (
+    CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+    CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
+    CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED,
+    CONF_ENTRY_OUTPUT_CODEC,
+    create_sample_rates_config_entry,
+)
+
+# Constants for players
+# both the http profile and icy didn't matter for me testing it.
+PLAYER_CONFIG_ENTRIES = (
+    CONF_ENTRY_OUTPUT_CODEC,
+    CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
+    CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED,
+    CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
+    create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24),
+)
+# player id is {device_id}{ZONE_SPLITTER}{zone_name}
+PLAYER_ZONE_SPLITTER = "___"  # must be url ok
+
+# Switch to these non netusb sources when leaving a group as a dev
+# with multiple zones. Optionally turn device off.
+CONF_PLAYER_SWITCH_SOURCE_NON_NET = "main_switch_source"
+CONF_PLAYER_TURN_OFF_ON_LEAVE = "turn_off_on_leave"
+MAIN_SWITCH_SOURCE_NON_NET = "audio1"
+PLAYER_ZONE2_SWITCH_SOURCE_NON_NET = "audio2"
+PLAYER_ZONE3_SWITCH_SOURCE_NON_NET = "audio3"
+PLAYER_ZONE4_SWITCH_SOURCE_NON_NET = "audio4"
+PLAYER_MAP_ZONE_SWITCH = {
+    "main": MAIN_SWITCH_SOURCE_NON_NET,
+    "zone2": PLAYER_ZONE2_SWITCH_SOURCE_NON_NET,
+    "zone3": PLAYER_ZONE3_SWITCH_SOURCE_NON_NET,
+    "zone4": PLAYER_ZONE4_SWITCH_SOURCE_NON_NET,
+}
+
+
+# MusicCast constants
+MC_POLL_INTERVAL = 10
+MC_PLAY_TITLE = "Music Assistant"
+
+MC_DEVICE_INFO_ENDPOINT = "YamahaExtendedControl/v1/system/getDeviceInfo"
+MC_DEVICE_UPNP_ENDPOINT = "MediaRenderer/desc.xml"
+# if this is not a constant, we'll have to do some xml parsing
+MC_DEVICE_UPNP_CTRL_ENDPOINT = "AVTransport/ctrl"
+MC_DEVICE_UPNP_PORT = 49154
+MC_NULL_GROUP = "00000000000000000000000000000000"
+MC_DEFAULT_ZONE = "main"
+
+MC_SOURCE_MC_LINK = "mc_link"
+MC_SOURCE_MAIN_SYNC = "main_sync"
+MC_LINK_SOURCES = [MC_SOURCE_MC_LINK, MC_SOURCE_MAIN_SYNC]
+
+MC_PASSIVE_SOURCE_IDS = [MC_SOURCE_MC_LINK]
+MC_NETUSB_SOURCE_IDS = [
+    "napster",
+    "spotify",
+    "qobuz",
+    "tidal",
+    "deezer",
+    "amazon_music",
+    "alexa",
+    "airplay",
+    "usb",
+    "server",
+    "net_radio",
+    "bluetooth",
+    # these were in aiomusiccast/musiccast_media_content.py:
+    "pandora",
+    "rhapsody",
+    "siriusxm",
+    "juke",
+    "radiko",
+]
+MC_CONTROL_SOURCE_IDS = MC_NETUSB_SOURCE_IDS
+MC_CONTROL_SOURCE_IDS.append(
+    # tuner can be controlled, will change the station
+    "tuner",
+)
diff --git a/music_assistant/providers/musiccast/icon.svg b/music_assistant/providers/musiccast/icon.svg
new file mode 100644 (file)
index 0000000..845920c
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 25 25" version="1.1">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 1.5 0 L 23.5 0 C 24.328125 0 25 0.671875 25 1.5 L 25 23.5 C 25 24.328125 24.328125 25 23.5 25 L 1.5 25 C 0.671875 25 0 24.328125 0 23.5 L 0 1.5 C 0 0.671875 0.671875 0 1.5 0 Z M 1.5 0 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 10.386719 18.875 L 14.8125 7.125 L 16.113281 7.125 L 11.6875 18.875 Z M 10.386719 18.875 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 21.371094 18.875 L 16.945312 7.125 L 18.246094 7.125 L 22.671875 18.875 Z M 21.371094 18.875 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 2.636719 18.875 L 2.636719 7.125 L 3.875 7.125 L 3.875 18.875 Z M 2.636719 18.875 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 5.445312 18.875 L 5.445312 7.125 L 6.683594 7.125 L 6.683594 18.875 Z M 5.445312 18.875 "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 8.253906 18.875 L 8.253906 7.125 L 9.492188 7.125 L 9.492188 18.875 Z M 8.253906 18.875 "/>
+</g>
+</svg>
diff --git a/music_assistant/providers/musiccast/manifest.json b/music_assistant/providers/musiccast/manifest.json
new file mode 100644 (file)
index 0000000..ae24d76
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "type": "player",
+  "domain": "musiccast",
+  "name": "MusicCast",
+  "description": "MusicCast for Music Assistant",
+  "requirements": [
+    "aiomusiccast==0.14.8",
+    "setuptools>=1.0.0"
+  ],
+  "codeowners": [
+    "@fmunkes"
+  ],
+  "mdns_discovery": [
+    "_http._tcp.local."
+  ],
+  "documentation": "https://music-assistant.io/player-support/musiccast",
+  "multi_instance": false
+}
diff --git a/music_assistant/providers/musiccast/musiccast.py b/music_assistant/providers/musiccast/musiccast.py
new file mode 100644 (file)
index 0000000..ac0a913
--- /dev/null
@@ -0,0 +1,602 @@
+"""MusicCast Handling for Music Assistant.
+
+This is largely taken from the MusicCast integration in HomeAssistant,
+https://github.com/home-assistant/core/tree/dev/homeassistant/components/yamaha_musiccast
+and then adapted for MA.
+
+We have
+
+MusicCastController - only once, holds state information of MC network
+    MusicCastPhysicalDevice - AV Receiver, Boxes
+        MusicCastZoneDevice - Player entity, which can be controlled.
+"""
+
+import logging
+from collections.abc import Awaitable, Callable
+from contextlib import suppress
+from datetime import datetime
+from enum import Enum, auto
+from random import getrandbits
+from typing import cast
+
+from aiomusiccast.exceptions import MusicCastConnectionException, MusicCastGroupException
+from aiomusiccast.musiccast_device import MusicCastDevice
+
+from .constants import (
+    MC_DEFAULT_ZONE,
+    MC_NULL_GROUP,
+    MC_PLAY_TITLE,
+    MC_SOURCE_MAIN_SYNC,
+    MC_SOURCE_MC_LINK,
+)
+
+
+def random_uuid_hex() -> str:
+    """Generate a random UUID hex.
+
+    This uuid should not be used for cryptographically secure
+    operations.
+
+    Taken from HA.
+    """
+    return f"{getrandbits(32 * 4):032x}"
+
+
+class MusicCastPlayerState(Enum):
+    """MusicCastPlayerState."""
+
+    PLAYING = auto()
+    PAUSED = auto()
+    IDLE = auto()
+    OFF = auto()
+
+
+class MusicCastZoneDevice:
+    """Zone device.
+
+    A physical device may have different zones, though only a single zone
+    can be used for net playback (but the other ones can be synced internally).
+    """
+
+    def __init__(self, zone_name: str, physical_device: "MusicCastPhysicalDevice") -> None:
+        """Init."""
+        self.zone_name = zone_name  # this is not the friendly name
+        self.controller = physical_device.controller
+        self.device = physical_device.device
+        self.zone_data = self.device.data.zones.get(self.zone_name)
+        self.physical_device = physical_device
+
+        self.physical_device.register_group_update_callback(self._group_update)
+
+    async def _group_update(self) -> None:
+        for entity in self.controller.all_server_devices:
+            if self.device.group_reduce_by_source:
+                await entity._check_client_list()
+
+    @property
+    def source_id(self) -> str:
+        """ID of the current input source.
+
+        Internal source name.
+        """
+        zone = self.device.data.zones.get(self.zone_name)
+        assert zone is not None
+        assert isinstance(zone.input, str)
+        return zone.input
+
+    @property
+    def reverse_source_mapping(self) -> dict[str, str]:
+        """Return a mapping from the source label to the source name."""
+        return {v: k for k, v in self.source_mapping.items()}
+
+    @property
+    def source(self) -> str:
+        """Name of the current input source."""
+        return self.source_mapping.get(self.source_id, "UNKNOWN SOURCE")
+
+    @property
+    def source_mapping(self) -> dict[str, str]:
+        """Return a mapping of the actual source names to their labels configured in the App."""
+        assert self.zone_data is not None  # for type checking
+        result = {}
+        for input_ in self.zone_data.input_list:
+            label = self.device.data.input_names.get(input_, "")
+            if input_ != label and (
+                label in self.zone_data.input_list
+                or list(self.device.data.input_names.values()).count(label) > 1
+            ):
+                label += f" ({input_})"
+            if label == "":
+                label = input_
+            result[input_] = label
+        return result
+
+    @property
+    def is_netusb(self) -> bool:
+        """Controlled by network if true."""
+        return cast("bool", self.device.data.netusb_input == self.source_id)
+
+    @property
+    def is_tuner(self) -> bool:
+        """Tuner if true."""
+        return self.source_id == "tuner"
+
+    @property
+    def is_controlled_by_mass(self) -> bool:
+        """Controlled by mass if true."""
+        return self.source_id == "server" and self.media_title == MC_PLAY_TITLE
+
+    @property
+    def media_position(self) -> int | None:
+        """Position of current playing media in seconds."""
+        if self.is_netusb:
+            return cast("int", self.device.data.netusb_play_time)
+        return None
+
+    @property
+    def media_position_updated_at(self) -> float | None:
+        """When was the position of the current playing media valid."""
+        if self.is_netusb:
+            stamp: datetime = cast("datetime", self.device.data.netusb_play_time_updated)
+            return stamp.timestamp()
+
+        return None
+
+    @property
+    def is_network_server(self) -> bool:
+        """Return only true if the current entity is a network server.
+
+        I.e. not a main zone with an attached zone2.
+        """
+        return cast(
+            "bool",
+            self.device.data.group_role == "server"
+            and self.device.data.group_id != MC_NULL_GROUP
+            and self.zone_name == self.device.data.group_server_zone,
+        )
+
+    @property
+    def other_zones(self) -> list["MusicCastZoneDevice"]:
+        """Return media player entities of the other zones of this device."""
+        return [
+            entity
+            for entity in self.physical_device.zone_devices.values()
+            if entity != self and isinstance(entity, MusicCastZoneDevice)
+        ]
+
+    @property
+    def state(self) -> MusicCastPlayerState:
+        """Return the state of the player."""
+        assert self.zone_data is not None
+        if self.zone_data.power == "on":
+            if self.is_netusb and self.device.data.netusb_playback == "pause":
+                return MusicCastPlayerState.PAUSED
+            if self.is_netusb and self.device.data.netusb_playback == "stop":
+                return MusicCastPlayerState.IDLE
+            return MusicCastPlayerState.PLAYING
+        return MusicCastPlayerState.OFF
+
+    @property
+    def is_server(self) -> bool:
+        """Return whether the media player is the server/host of the group.
+
+        If the media player is not part of a group, False is returned.
+        """
+        return self.is_network_server or (
+            self.zone_name == MC_DEFAULT_ZONE
+            and len(
+                [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MAIN_SYNC]
+            )
+            > 0
+        )
+
+    @property
+    def is_network_client(self) -> bool:
+        """Return True if the current entity is a network client and not just a main sync entity."""
+        return (
+            self.device.data.group_role == "client"
+            and self.device.data.group_id != MC_NULL_GROUP
+            and self.source_id == MC_SOURCE_MC_LINK
+        )
+
+    @property
+    def is_client(self) -> bool:
+        """Return whether the media player is the client of a group.
+
+        If the media player is not part of a group, False is returned.
+        """
+        return self.is_network_client or self.source_id == MC_SOURCE_MAIN_SYNC
+
+    @property
+    def musiccast_zone_entity(self) -> "MusicCastZoneDevice":
+        """Return the musiccast entity of the physical device.
+
+        It is possible that multiple zones use MusicCast as client at the same time.
+        In this case the first one is returned.
+        """
+        for entity in self.other_zones:
+            if entity.is_network_server or entity.is_network_client:
+                return entity
+
+        return self
+
+    @property
+    def musiccast_group(self) -> list["MusicCastZoneDevice"]:
+        """Return all media players of the current group, if the media player is server."""
+        if self.is_client:
+            # If we are a client we can still share group information, but we will take them from
+            # the server.
+            if (server := self.group_server) != self:
+                return server.musiccast_group
+
+            return [self]
+        if not self.is_server:
+            return [self]
+        entities = self.controller.all_zone_devices
+        clients = [entity for entity in entities if entity.is_part_of_group(self)]
+        return [self, *clients]
+
+    @property
+    def group_server(self) -> "MusicCastZoneDevice":
+        """Return the server of the own group if present, self else."""
+        for entity in self.controller.all_server_devices:
+            if self.is_part_of_group(entity):
+                return entity
+        return self
+
+    @property
+    def media_title(self) -> str | None:
+        """Return the title of current playing media."""
+        if self.is_netusb:
+            return cast("str", self.device.data.netusb_track)
+        if self.is_tuner:
+            return cast("str", self.device.tuner_media_title)
+
+        return None
+
+    @property
+    def media_image_url(self) -> str | None:
+        """Return the image url of current playing media."""
+        if self.is_client and self.group_server != self:
+            return cast("str", self.group_server.device.media_image_url)
+        return cast("str", self.device.media_image_url) if self.is_netusb else None
+
+    @property
+    def media_artist(self) -> str | None:
+        """Return the artist of current playing media (Music track only)."""
+        if self.is_netusb:
+            return cast("str", self.device.data.netusb_artist)
+        if self.is_tuner:
+            return cast("str", self.device.tuner_media_artist)
+
+        return None
+
+    @property
+    def media_album_name(self) -> str | None:
+        """Return the album of current playing media (Music track only)."""
+        return cast("str", self.device.data.netusb_album) if self.is_netusb else None
+
+    async def turn_on(self) -> None:
+        """Turn on."""
+        await self.device.turn_on(self.zone_name)
+
+    async def turn_off(self) -> None:
+        """Turn off."""
+        await self.device.turn_off(self.zone_name)
+
+    async def volume_mute(self, mute: bool) -> None:
+        """Volume mute."""
+        await self.device.mute_volume(self.zone_name, mute)
+
+    async def volume_set(self, volume_level: int) -> None:
+        """Volume set."""
+        await self.device.set_volume_level(self.zone_name, volume_level / 100)
+
+    async def play(self) -> None:
+        """Play."""
+        if self.is_netusb:
+            await self.device.netusb_play()
+
+    async def pause(self) -> None:
+        """Pause."""
+        if self.is_netusb:
+            await self.device.netusb_pause()
+
+    async def stop(self) -> None:
+        """Stop."""
+        if self.is_netusb:
+            await self.device.netusb_stop()
+
+    async def previous_track(self) -> None:
+        """Send previous track command."""
+        if self.is_netusb:
+            await self.device.netusb_previous_track()
+        elif self.is_tuner:
+            await self.device.tuner_previous_station()
+
+    async def next_track(self) -> None:
+        """Send next track command."""
+        if self.is_netusb:
+            await self.device.netusb_next_track()
+        elif self.is_tuner:
+            await self.device.tuner_next_station()
+
+    async def play_url(self, url: str) -> None:
+        """Play http url."""
+        await self.device.play_url_media(self.zone_name, media_url=url, title=MC_PLAY_TITLE)
+
+    async def select_source(self, source_id: str) -> None:
+        """Select input source. Internal source name."""
+        await self.device.select_source(self.zone_name, source_id)
+
+    def is_part_of_group(self, group_server: "MusicCastZoneDevice") -> bool:
+        """Return True if the given server is the server of self's group."""
+        return group_server != self and (
+            (
+                self.device.ip in group_server.device.data.group_client_list
+                and self.device.data.group_id == group_server.device.data.group_id
+                and self.device.ip != group_server.device.ip
+                and self.source_id == MC_SOURCE_MC_LINK
+            )
+            or (self.device.ip == group_server.device.ip and self.source_id == MC_SOURCE_MAIN_SYNC)
+        )
+
+    async def join_players(self, group_members: list["MusicCastZoneDevice"]) -> None:
+        """Add all clients given in entities to the group of the server.
+
+        Creates a new group if necessary. Used for join service.
+        """
+        assert self.zone_data is not None
+        if self.state == MusicCastPlayerState.OFF:
+            await self.turn_on()
+
+        if not self.is_server and self.musiccast_zone_entity.is_server:
+            # The MusicCast Distribution Module of this device is already in use. To use it as a
+            # server, we first have to unjoin and wait until the servers are updated.
+            await self.musiccast_zone_entity._server_close_group()
+        elif self.musiccast_zone_entity.is_client:
+            await self._client_leave_group(True)
+        # Use existing group id if we are server, generate a new one else.
+        group_id = self.device.data.group_id if self.is_server else random_uuid_hex().upper()
+        assert group_id is not None  # for type checking
+
+        ip_addresses = set()
+        # First let the clients join
+        for client in group_members:
+            if client != self:
+                try:
+                    network_join = await client._client_join(group_id, self)
+                except MusicCastGroupException:
+                    network_join = await client._client_join(group_id, self)
+
+                if network_join:
+                    ip_addresses.add(client.device.ip)
+
+        if ip_addresses:
+            await self.device.mc_server_group_extend(
+                self.zone_name,
+                list(ip_addresses),
+                group_id,
+                self.controller.distribution_num,
+            )
+
+        await self._group_update()
+
+    async def unjoin_player(self) -> None:
+        """Leave the group.
+
+        Stops the distribution if device is server. Used for unjoin service.
+        """
+        if self.is_server:
+            await self._server_close_group()
+        else:
+            # this is not as in HA
+            await self._client_leave_group(True)
+
+    # Internal client functions
+
+    async def _client_join(self, group_id: str, server: "MusicCastZoneDevice") -> bool:
+        """Let the client join a group.
+
+        If this client is a server, the server will stop distributing.
+        If the client is part of a different group,
+        it will leave that group first. Returns True, if the server has to
+        add the client on his side.
+        """
+        # If we should join the group, which is served by the main zone,
+        # we can simply select main_sync as input.
+        if self.state == MusicCastPlayerState.OFF:
+            await self.turn_on()
+        if self.device.ip == server.device.ip:
+            if server.zone_name == MC_DEFAULT_ZONE:
+                await self.select_source(MC_SOURCE_MAIN_SYNC)
+                return False
+
+            # It is not possible to join a group hosted by zone2 from main zone.
+            # raise?
+            return False
+
+        if self.musiccast_zone_entity.is_server:
+            # If one of the zones of the device is a server, we need to unjoin first.
+            await self.musiccast_zone_entity._server_close_group()
+
+        elif self.is_client:
+            if self.is_part_of_group(server):
+                return False
+
+            await self._client_leave_group()
+
+        elif (
+            self.device.ip in server.device.data.group_client_list
+            and self.device.data.group_id == server.device.data.group_id
+            and self.device.data.group_role == "client"
+        ):
+            # The device is already part of this group (e.g. main zone is also a client of this
+            # group).
+            # Just select mc_link as source
+            await self.device.zone_join(self.zone_name)
+            return False
+
+        await self.device.mc_client_join(server.device.ip, group_id, self.zone_name)
+        return True
+
+    async def _client_leave_group(self, force: bool = False) -> None:
+        """Make self leave the group.
+
+        Should only be called for clients.
+        """
+        if not force and (
+            self.source_id == MC_SOURCE_MAIN_SYNC
+            or [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MC_LINK]
+        ):
+            await self.device.zone_unjoin(self.zone_name)
+        else:
+            servers = [
+                server
+                for server in self.controller.all_server_devices
+                if server.device.data.group_id == self.device.data.group_id
+            ]
+            await self.device.mc_client_unjoin()
+            if servers:
+                await servers[0].device.mc_server_group_reduce(
+                    servers[0].zone_name,
+                    [self.device.ip],
+                    self.controller.distribution_num,
+                )
+
+    # Internal server functions
+
+    async def _server_close_group(self) -> None:
+        """Close group of self.
+
+        Should only be called for servers.
+        """
+        for client in self.musiccast_group:
+            if client != self:
+                await client._client_leave_group()
+        await self.device.mc_server_group_close()
+
+    async def _check_client_list(self) -> None:
+        """Let the server check if all its clients are still part of his group."""
+        if not self.is_server or self.device.data.group_update_lock.locked():
+            return
+
+        client_ips_for_removal = [
+            expected_client_ip
+            for expected_client_ip in self.device.data.group_client_list
+            # The client is no longer part of the group. Prepare removal.
+            if expected_client_ip not in [entity.device.ip for entity in self.musiccast_group]
+        ]
+
+        if client_ips_for_removal:
+            await self.device.mc_server_group_reduce(
+                self.zone_name, client_ips_for_removal, self.controller.distribution_num
+            )
+        if len(self.musiccast_group) < 2:
+            # The group is empty, stop distribution.
+            await self._server_close_group()
+
+
+class MusicCastPhysicalDevice:
+    """Physical MusicCast device.
+
+    May contain multiple zone devices, but at least one, main.
+    """
+
+    def __init__(
+        self,
+        device: MusicCastDevice,
+        controller: "MusicCastController",
+    ):
+        """Init."""
+        self.device = device
+        self.zone_devices: dict[str, MusicCastZoneDevice] = {}  # zone_name: device
+        self.controller = controller
+        self.controller.physical_devices.append(self)
+
+    async def async_init(self) -> bool:
+        """Async init.
+
+        Returns true if initial fetch was successful.
+        """
+        try:
+            await self.fetch()
+        except (MusicCastConnectionException, MusicCastGroupException):
+            return False
+
+        self.device.build_capabilities()
+
+        # enable udp polling
+        await self.enable_polling()
+
+        for zone_name in self.device.data.zones:
+            self.zone_devices[zone_name] = MusicCastZoneDevice(zone_name, self)
+
+        return True
+
+    async def enable_polling(self) -> None:
+        """Enable udp polling."""
+        await self.device.device.enable_polling()
+
+    def disable_polling(self) -> None:
+        """Disable udp polling."""
+        self.device.device.disable_polling()
+
+    async def fetch(self) -> None:
+        """Fetch device information.
+
+        Should be called regularly, e.g. every 60s, in case some udp info
+        goes missing.
+        """
+        await self.device.fetch()
+
+    def register_callback(self, fun: Callable[["MusicCastPhysicalDevice"], None]) -> None:
+        """Register a non-async callback."""
+
+        def _cb() -> None:
+            fun(self)
+
+        self.device.register_callback(_cb)
+
+    def register_group_update_callback(self, fun: Callable[[], Awaitable[None]]) -> None:
+        """Register an async group update callback."""
+        self.device.register_group_update_callback(fun)
+
+    def remove(self) -> None:
+        """Remove physical device."""
+        with suppress(AttributeError):
+            # might already be closed
+            self.device.device.disable_polling()
+        with suppress(ValueError):
+            # might already be closed
+            self.controller.physical_devices.remove(self)
+
+
+class MusicCastController:
+    """MusicCastController.
+
+    Holds information of full known MC network.
+    """
+
+    def __init__(self, logger: logging.Logger) -> None:
+        """Init."""
+        self.physical_devices: list[MusicCastPhysicalDevice] = []
+        self.logger = logger
+
+    @property
+    def distribution_num(self) -> int:
+        """Return the distribution_num (number of clients in the whole musiccast system)."""
+        return sum(len(x.zone_devices) for x in self.physical_devices)
+
+    @property
+    def all_zone_devices(self) -> list[MusicCastZoneDevice]:
+        """Return all zone devices."""
+        result = []
+        for physical_device in self.physical_devices:
+            result.extend(list(physical_device.zone_devices.values()))
+        return result
+
+    @property
+    def all_server_devices(self) -> list[MusicCastZoneDevice]:
+        """Return server devices."""
+        return [x for x in self.all_zone_devices if x.is_server]
index d25c3b9d3ad5fa265dc1a527b24ed30061b031de..8bbb233cabf1dfdfbaba944402fcf6d1a97580e5 100644 (file)
@@ -30,8 +30,8 @@ from music_assistant.constants import (
     VERBOSE_LOG_LEVEL,
     create_sample_rates_config_entry,
 )
-from music_assistant.helpers.didl_lite import create_didl_metadata_str
 from music_assistant.helpers.tags import async_parse_tags
+from music_assistant.helpers.upnp import create_didl_metadata_str
 from music_assistant.models.player_provider import PlayerProvider
 
 from .const import CONF_AIRPLAY_MODE
index 72c4090d49e4b1e6f96df22922f4111dbbf4de1e..2df049c4aceb2711eae57a01d9576ce814ac4328 100644 (file)
@@ -38,7 +38,7 @@ from music_assistant.constants import (
     VERBOSE_LOG_LEVEL,
     create_sample_rates_config_entry,
 )
-from music_assistant.helpers.didl_lite import create_didl_metadata
+from music_assistant.helpers.upnp import create_didl_metadata
 from music_assistant.models.player_provider import PlayerProvider
 
 from .player import SonosPlayer
index d39c7eeb5f8015a9ddda7a4f65b78bd759412063..5d34d5a60c9a5ec00bc1fc4df151d51e26473f1f 100644 (file)
@@ -7,6 +7,7 @@ aiofiles==24.1.0
 aiohttp==3.11.18
 aiohttp-fast-zlib==0.2.3
 aiojellyfin==0.14.1
+aiomusiccast==0.14.8
 aiorun==2025.1.1
 aioslimproto==3.1.0
 aiosonos==0.1.9
@@ -44,6 +45,7 @@ python-fullykiosk==0.0.14
 python-slugify==8.0.4
 pywidevine==1.8.0
 radios==0.3.2
+setuptools>=1.0.0
 shortuuid==1.0.13
 snapcast==2.3.6
 soco==0.30.9