| 
 | 1 | +"""  | 
 | 2 | +VBAN protocol receiver plugin for Music Assistant.  | 
 | 3 | +
  | 
 | 4 | +We tie a single player to a single VBAN session name.  | 
 | 5 | +The provider has multi instance support,  | 
 | 6 | +so multiple players can be linked to multiple VBAN streams.  | 
 | 7 | +"""  | 
 | 8 | + | 
 | 9 | +from __future__ import annotations  | 
 | 10 | + | 
 | 11 | +import asyncio  | 
 | 12 | +import re  | 
 | 13 | +from collections.abc import AsyncGenerator  | 
 | 14 | +from contextlib import suppress  | 
 | 15 | +from typing import TYPE_CHECKING, cast  | 
 | 16 | + | 
 | 17 | +from aiovban.asyncio import AsyncVBANClient  | 
 | 18 | +from aiovban.asyncio.util import BackPressureStrategy  | 
 | 19 | +from aiovban.enums import VBANSampleRate  | 
 | 20 | +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption  | 
 | 21 | +from music_assistant_models.enums import (  | 
 | 22 | +    ConfigEntryType,  | 
 | 23 | +    ContentType,  | 
 | 24 | +    MediaType,  | 
 | 25 | +    ProviderFeature,  | 
 | 26 | +    StreamType,  | 
 | 27 | +)  | 
 | 28 | +from music_assistant_models.errors import SetupFailedError  | 
 | 29 | +from music_assistant_models.media_items import AudioFormat  | 
 | 30 | + | 
 | 31 | +from music_assistant.constants import (  | 
 | 32 | +    CONF_BIND_IP,  | 
 | 33 | +    CONF_BIND_PORT,  | 
 | 34 | +    CONF_ENTRY_WARN_PREVIEW,  | 
 | 35 | +)  | 
 | 36 | +from music_assistant.helpers.util import (  | 
 | 37 | +    get_ip_addresses,  | 
 | 38 | +)  | 
 | 39 | +from music_assistant.models.player import PlayerMedia  | 
 | 40 | +from music_assistant.models.plugin import PluginProvider, PluginSource  | 
 | 41 | + | 
 | 42 | +if TYPE_CHECKING:  | 
 | 43 | +    from aiovban.asyncio.device import VBANDevice  | 
 | 44 | +    from aiovban.asyncio.streams import VBANIncomingStream  | 
 | 45 | +    from music_assistant_models.config_entries import ConfigValueType, ProviderConfig  | 
 | 46 | +    from music_assistant_models.provider import ProviderManifest  | 
 | 47 | + | 
 | 48 | +    from music_assistant.mass import MusicAssistant  | 
 | 49 | +    from music_assistant.models import ProviderInstanceType  | 
 | 50 | + | 
 | 51 | +DEFAULT_UDP_PORT = 6980  | 
 | 52 | +DEFAULT_PCM_AUDIO_FORMAT = "S16LE"  | 
 | 53 | +DEFAULT_PCM_SAMPLE_RATE = 44100  | 
 | 54 | + | 
 | 55 | +CONF_VBAN_STREAM_NAME = "vban_stream_name"  | 
 | 56 | +CONF_SENDER_HOST = "sender_host"  | 
 | 57 | +CONF_PCM_AUDIO_FORMAT = "audio_format"  | 
 | 58 | +CONF_PCM_SAMPLE_RATE = "sample_rate"  | 
 | 59 | + | 
 | 60 | +SUPPORTED_FEATURES = {ProviderFeature.AUDIO_SOURCE}  | 
 | 61 | + | 
 | 62 | + | 
 | 63 | +def _get_supported_pcm_formats() -> dict[str, int]:  | 
 | 64 | +    """Return supported PCM formats."""  | 
 | 65 | +    pcm_formats = {}  | 
 | 66 | +    for content_type in ContentType.__members__:  | 
 | 67 | +        if match := re.match(r"PCM_([S|F](\d{2})LE)", content_type):  | 
 | 68 | +            pcm_formats[match.group(1)] = int(match.group(2))  | 
 | 69 | +    return pcm_formats  | 
 | 70 | + | 
 | 71 | + | 
 | 72 | +def _get_vban_sample_rates() -> list[str]:  | 
 | 73 | +    """Return supported VBAN sample rates."""  | 
 | 74 | +    return [member.split("_")[1] for member in VBANSampleRate.__members__]  | 
 | 75 | + | 
 | 76 | + | 
 | 77 | +async def setup(  | 
 | 78 | +    mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig  | 
 | 79 | +) -> ProviderInstanceType:  | 
 | 80 | +    """Initialize provider(instance) with given configuration."""  | 
 | 81 | +    return VBANReceiverProvider(mass, manifest, config)  | 
 | 82 | + | 
 | 83 | + | 
 | 84 | +async def get_config_entries(  | 
 | 85 | +    mass: MusicAssistant,  # noqa: ARG001  | 
 | 86 | +    instance_id: str | None = None,  # noqa: ARG001  | 
 | 87 | +    action: str | None = None,  # noqa: ARG001  | 
 | 88 | +    values: dict[str, ConfigValueType] | None = None,  # noqa: ARG001  | 
 | 89 | +) -> tuple[ConfigEntry, ...]:  | 
 | 90 | +    """  | 
 | 91 | +    Return Config entries to setup this provider.  | 
 | 92 | +
  | 
 | 93 | +    instance_id: id of an existing provider instance (None if new instance setup).  | 
 | 94 | +    action: [optional] action key called from config entries UI.  | 
 | 95 | +    values: the (intermediate) raw values for config entries sent with the action.  | 
 | 96 | +    """  | 
 | 97 | +    ip_addresses = await get_ip_addresses()  | 
 | 98 | + | 
 | 99 | +    def _validate_stream_name(config_value: str) -> bool:  | 
 | 100 | +        """Validate stream name."""  | 
 | 101 | +        try:  | 
 | 102 | +            config_value.encode("ascii")  | 
 | 103 | +        except UnicodeEncodeError:  | 
 | 104 | +            return False  | 
 | 105 | +        return len(config_value) < 17  | 
 | 106 | + | 
 | 107 | +    return (  | 
 | 108 | +        CONF_ENTRY_WARN_PREVIEW,  | 
 | 109 | +        ConfigEntry(  | 
 | 110 | +            key=CONF_BIND_PORT,  | 
 | 111 | +            type=ConfigEntryType.INTEGER,  | 
 | 112 | +            default_value=DEFAULT_UDP_PORT,  | 
 | 113 | +            label="Receiver: UDP Port",  | 
 | 114 | +            description="The UDP port the VBAN receiver will listen on for connections. "  | 
 | 115 | +            "Make sure that this server can be reached "  | 
 | 116 | +            "on the given IP and UDP port by remote VBAN senders.",  | 
 | 117 | +        ),  | 
 | 118 | +        ConfigEntry(  | 
 | 119 | +            key=CONF_VBAN_STREAM_NAME,  | 
 | 120 | +            type=ConfigEntryType.STRING,  | 
 | 121 | +            label="Sender: VBAN Stream Name",  | 
 | 122 | +            default_value="Network AUX",  | 
 | 123 | +            description="Max 16 ASCII chars.\n"  | 
 | 124 | +            "The VBAN stream name to expect from the remote VBAN sender.\n"  | 
 | 125 | +            "This MUST match what the remote VBAN sender has set for the session name "  | 
 | 126 | +            "otherwise audio streaming will not work.",  | 
 | 127 | +            required=True,  | 
 | 128 | +            validate=_validate_stream_name,  # type: ignore[arg-type]  | 
 | 129 | +        ),  | 
 | 130 | +        ConfigEntry(  | 
 | 131 | +            key=CONF_SENDER_HOST,  | 
 | 132 | +            type=ConfigEntryType.STRING,  | 
 | 133 | +            default_value="127.0.0.1",  | 
 | 134 | +            label="Sender: VBAN Sender hostname/IP address",  | 
 | 135 | +            description="The hostname/IP Address of the remote VBAN SENDER.",  | 
 | 136 | +            required=True,  | 
 | 137 | +        ),  | 
 | 138 | +        ConfigEntry(  | 
 | 139 | +            key=CONF_PCM_AUDIO_FORMAT,  | 
 | 140 | +            type=ConfigEntryType.STRING,  | 
 | 141 | +            default_value=DEFAULT_PCM_AUDIO_FORMAT,  | 
 | 142 | +            options=[ConfigValueOption(x, x) for x in _get_supported_pcm_formats()],  | 
 | 143 | +            label="PCM audio format",  | 
 | 144 | +            description="The VBAN PCM audio format to expect from the remote VBAN sender. "  | 
 | 145 | +            "This MUST match what the remote VBAN sender has set otherwise audio streaming "  | 
 | 146 | +            "will not work.",  | 
 | 147 | +            required=True,  | 
 | 148 | +        ),  | 
 | 149 | +        ConfigEntry(  | 
 | 150 | +            key=CONF_PCM_SAMPLE_RATE,  | 
 | 151 | +            type=ConfigEntryType.STRING,  | 
 | 152 | +            default_value=DEFAULT_PCM_SAMPLE_RATE,  | 
 | 153 | +            options=[ConfigValueOption(x, x) for x in _get_vban_sample_rates()],  | 
 | 154 | +            label="PCM sample rate",  | 
 | 155 | +            description="The VBAN PCM sample rate to expect from the remote VBAN sender. "  | 
 | 156 | +            "This MUST match what the remote VBAN sender has set otherwise audio streaming "  | 
 | 157 | +            "will not work.",  | 
 | 158 | +            required=True,  | 
 | 159 | +        ),  | 
 | 160 | +        ConfigEntry(  | 
 | 161 | +            key=CONF_BIND_IP,  | 
 | 162 | +            type=ConfigEntryType.STRING,  | 
 | 163 | +            default_value="0.0.0.0",  | 
 | 164 | +            options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],  | 
 | 165 | +            label="Receiver: Bind to IP/interface",  | 
 | 166 | +            description="Start the VBAN receiver on this specific interface. \n"  | 
 | 167 | +            "Use 0.0.0.0 to bind to all interfaces, which is the default. \n"  | 
 | 168 | +            "This is an advanced setting that should normally "  | 
 | 169 | +            "not be adjusted in regular setups.",  | 
 | 170 | +            category="advanced",  | 
 | 171 | +            required=True,  | 
 | 172 | +        ),  | 
 | 173 | +    )  | 
 | 174 | + | 
 | 175 | + | 
 | 176 | +class VBANReceiverProvider(PluginProvider):  | 
 | 177 | +    """Implementation of a VBAN protocol receiver plugin."""  | 
 | 178 | + | 
 | 179 | +    def __init__(  | 
 | 180 | +        self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig  | 
 | 181 | +    ) -> None:  | 
 | 182 | +        """Initialize MusicProvider."""  | 
 | 183 | +        super().__init__(mass, manifest, config, SUPPORTED_FEATURES)  | 
 | 184 | +        self.logger = self.logger.getChild(self.instance_id.split("--")[1])  | 
 | 185 | +        self._bind_port: int = cast("int", self.config.get_value(CONF_BIND_PORT))  | 
 | 186 | +        self._bind_ip: str = cast("str", self.config.get_value(CONF_BIND_IP))  | 
 | 187 | +        self._sender_host: str = cast("str", self.config.get_value(CONF_SENDER_HOST))  | 
 | 188 | +        self._vban_stream_name: str = cast("str", self.config.get_value(CONF_VBAN_STREAM_NAME))  | 
 | 189 | +        self._pcm_audio_format: str = cast("str", self.config.get_value(CONF_PCM_AUDIO_FORMAT))  | 
 | 190 | +        self._pcm_sample_rate: int = cast("int", self.config.get_value(CONF_PCM_SAMPLE_RATE))  | 
 | 191 | + | 
 | 192 | +        self._vban_receiver: AsyncVBANClient | None = None  | 
 | 193 | +        self._vban_device: VBANDevice | None = None  | 
 | 194 | +        self._vban_stream: VBANIncomingStream | None = None  | 
 | 195 | + | 
 | 196 | +        self._source_details = PluginSource(  | 
 | 197 | +            id=self.instance_id,  | 
 | 198 | +            name=f"{self.manifest.name}: {self._vban_stream_name}",  | 
 | 199 | +            passive=False,  | 
 | 200 | +            can_play_pause=False,  | 
 | 201 | +            can_seek=False,  | 
 | 202 | +            can_next_previous=False,  | 
 | 203 | +            audio_format=AudioFormat(  | 
 | 204 | +                content_type=ContentType(self._pcm_audio_format.lower()),  | 
 | 205 | +                codec_type=ContentType(self._pcm_audio_format.lower()),  | 
 | 206 | +                sample_rate=self._pcm_sample_rate,  | 
 | 207 | +                bit_depth=_get_supported_pcm_formats()[self._pcm_audio_format],  | 
 | 208 | +                channels=2,  | 
 | 209 | +            ),  | 
 | 210 | +            metadata=PlayerMedia(  | 
 | 211 | +                "VBAN Receiver",  | 
 | 212 | +                artist=self._sender_host,  | 
 | 213 | +                title=self._vban_stream_name,  | 
 | 214 | +                media_type=MediaType.PLUGIN_SOURCE,  | 
 | 215 | +            ),  | 
 | 216 | +            stream_type=StreamType.CUSTOM,  | 
 | 217 | +        )  | 
 | 218 | + | 
 | 219 | +    @property  | 
 | 220 | +    def supported_features(self) -> set[ProviderFeature]:  | 
 | 221 | +        """Return the features supported by this Provider."""  | 
 | 222 | +        return {ProviderFeature.AUDIO_SOURCE}  | 
 | 223 | + | 
 | 224 | +    @property  | 
 | 225 | +    def instance_name_postfix(self) -> str | None:  | 
 | 226 | +        """Return a (default) instance name postfix for this provider instance."""  | 
 | 227 | +        return self._vban_stream_name  | 
 | 228 | + | 
 | 229 | +    async def handle_async_init(self) -> None:  | 
 | 230 | +        """Handle async initialization of the provider."""  | 
 | 231 | +        self._vban_receiver = AsyncVBANClient(ignore_audio_streams=False)  | 
 | 232 | +        try:  | 
 | 233 | +            result = await self._vban_receiver.listen(self._bind_ip, self._bind_port)  | 
 | 234 | +        except OSError as err:  | 
 | 235 | +            raise SetupFailedError(f"Failed to start VBAN receiver plugin: {err}") from err  | 
 | 236 | +        else:  | 
 | 237 | +            self._udp_socket_fut = result  | 
 | 238 | + | 
 | 239 | +        self._vban_device = self._vban_receiver.register_device(self._sender_host, self._bind_port)  | 
 | 240 | +        if self._vban_device:  | 
 | 241 | +            self._vban_stream = self._vban_device.receive_stream(  | 
 | 242 | +                self._vban_stream_name, back_pressure_strategy=BackPressureStrategy.DRAIN_OLDEST  | 
 | 243 | +            )  | 
 | 244 | + | 
 | 245 | +    async def unload(self, is_removed: bool = False) -> None:  | 
 | 246 | +        """Handle close/cleanup of the provider."""  | 
 | 247 | +        self.logger.debug("Unloading plugin")  | 
 | 248 | +        if self._vban_receiver:  | 
 | 249 | +            self.logger.info("Closing UDP transport")  | 
 | 250 | +            # Can raise an uncatchable exception due to bug in aiovban library.  | 
 | 251 | +            self._vban_receiver.close()  | 
 | 252 | + | 
 | 253 | +        if self._udp_socket_fut and not self._udp_socket_fut.done():  | 
 | 254 | +            self._udp_socket_fut.cancel()  | 
 | 255 | +            with suppress(asyncio.CancelledError):  | 
 | 256 | +                await self._udp_socket_fut  | 
 | 257 | + | 
 | 258 | +        self._udp_socket_fut = None  | 
 | 259 | +        self._vban_receiver = None  | 
 | 260 | +        self._vban_device = None  | 
 | 261 | +        self._vban_stream = None  | 
 | 262 | +        await asyncio.sleep(0.1)  | 
 | 263 | + | 
 | 264 | +    def get_source(self) -> PluginSource:  | 
 | 265 | +        """Get (audio)source details for this plugin."""  | 
 | 266 | +        return self._source_details  | 
 | 267 | + | 
 | 268 | +    async def get_audio_stream(self, player_id: str) -> AsyncGenerator[bytes, None]:  | 
 | 269 | +        """Yield raw PCM chunks from the VBANIncomingStream queue."""  | 
 | 270 | +        self.logger.debug(  | 
 | 271 | +            "Sending VBAN PCM audio stream for Player: %s//Stream: %s//Config: %s",  | 
 | 272 | +            player_id,  | 
 | 273 | +            self._vban_stream_name,  | 
 | 274 | +            self._source_details.audio_format.output_format_str,  # type: ignore[union-attr]  | 
 | 275 | +        )  | 
 | 276 | +        while (  | 
 | 277 | +            self._source_details.in_use_by and self._vban_stream and not self._udp_socket_fut.done()  | 
 | 278 | +        ):  | 
 | 279 | +            try:  | 
 | 280 | +                packet = await self._vban_stream.get_packet()  | 
 | 281 | +            except asyncio.QueueShutDown:  # type: ignore[attr-defined]  | 
 | 282 | +                self.logger.error(  | 
 | 283 | +                    "Found VBANIncomingStream queue shut down when attempting to get VBAN packet"  | 
 | 284 | +                )  | 
 | 285 | +                raise  | 
 | 286 | + | 
 | 287 | +            # Skip processing full null packets.  | 
 | 288 | +            # pipewire vban-send module constantly sends full null VBAN packets when a "Stream"  | 
 | 289 | +            # is established e.g when squeezelite starts up with the vban-send sink as its  | 
 | 290 | +            # output device.  | 
 | 291 | +            if packet.body.data == bytes(len(packet.body.data)):  | 
 | 292 | +                continue  | 
 | 293 | + | 
 | 294 | +            yield packet.body.data  | 
0 commit comments