From: Fabian Munkes <105975993+fmunkes@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:07:17 +0000 (+0200) Subject: Add Yamaha MusicCast provider (#2142) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=750865d8a5e0992dec13ac02888c19f60d7a108c;p=music-assistant-server.git Add Yamaha MusicCast provider (#2142) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 64d0076a..ac5bc287 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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 index 2e0738e2..00000000 --- a/music_assistant/helpers/didl_lite.py +++ /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 ( - '' - f'' - f"{xmlescape(title)}" - f"{xmlescape(image_url)}" - f"{media.uri}" - "object.item.audioItem.audioBroadcast" - f"audio/{ext}" - f'{xmlescape(media.uri)}' - "" - "" - ) - duration_str = str(datetime.timedelta(seconds=media.duration or 0)) + ".000" - - return ( - '' - f'' - f"{xmlescape(media.title or media.uri)}" - f"{xmlescape(media.artist or '')}" - f"{xmlescape(media.album or '')}" - f"{xmlescape(media.artist or '')}" - f"{int(media.duration or 0)}" - f"{xmlescape(media.queue_item_id)}" - f"Music Assistant" - f"{xmlescape(image_url)}" - "object.item.audioItem.musicTrack" - f"audio/{ext}" - f'{xmlescape(media.uri)}' - 'RINCON_AssociatedZPUDN' - "" - "" - ) - - -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 index 00000000..b560f3e5 --- /dev/null +++ b/music_assistant/helpers/upnp.py @@ -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'' + r"0" + f"{arguments}" + f"" + ) + + +def _get_xml(body: str) -> str: + return ( + r'' + r'' + r"" + f"{body}" + r"" + r"" + ) + + +def get_xml_soap_play() -> tuple[str, str]: + """Get UPnP xml and soap for Play.""" + command = "Play" + arguments = r"1" + 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"{player_media.uri}" + "" + f"{metadata}" + "" + ) + 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"{player_media.uri}{metadata}" + ) + 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 ( + '' + f'' + f"{xmlescape(title)}" + f"{xmlescape(image_url)}" + f"{media.uri}" + "object.item.audioItem.audioBroadcast" + f"audio/{ext}" + f'{xmlescape(media.uri)}' + "" + "" + ) + duration_str = str(datetime.timedelta(seconds=media.duration or 0)) + ".000" + + return ( + '' + f'' + f"{xmlescape(media.title or media.uri)}" + f"{xmlescape(media.artist or '')}" + f"{xmlescape(media.album or '')}" + f"{xmlescape(media.artist or '')}" + f"{int(media.duration or 0)}" + f"{xmlescape(media.queue_item_id)}" + f"Music Assistant" + f"{xmlescape(image_url)}" + "object.item.audioItem.musicTrack" + f"audio/{ext}" + f'{xmlescape(media.uri)}' + 'RINCON_AssociatedZPUDN' + "" + "" + ) + + +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/providers/dlna/__init__.py b/music_assistant/providers/dlna/__init__.py index c8ed06d1..ca9d5526 100644 --- a/music_assistant/providers/dlna/__init__.py +++ b/music_assistant/providers/dlna/__init__.py @@ -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 index 00000000..47acd9ea --- /dev/null +++ b/music_assistant/providers/musiccast/__init__.py @@ -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 index 00000000..c71602da --- /dev/null +++ b/music_assistant/providers/musiccast/avt_helpers.py @@ -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"" + 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 index 00000000..911ae5cc --- /dev/null +++ b/music_assistant/providers/musiccast/constants.py @@ -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 index 00000000..845920ca --- /dev/null +++ b/music_assistant/providers/musiccast/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/music_assistant/providers/musiccast/manifest.json b/music_assistant/providers/musiccast/manifest.json new file mode 100644 index 00000000..ae24d76b --- /dev/null +++ b/music_assistant/providers/musiccast/manifest.json @@ -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 index 00000000..ac0a9134 --- /dev/null +++ b/music_assistant/providers/musiccast/musiccast.py @@ -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] diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index d25c3b9d..8bbb233c 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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 diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py index 72c4090d..2df049c4 100644 --- a/music_assistant/providers/sonos_s1/__init__.py +++ b/music_assistant/providers/sonos_s1/__init__.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index d39c7eeb..5d34d5a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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