|
| 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