/
/
/
1"""Various helpers/utilities for the AirPlay provider."""
2
3from __future__ import annotations
4
5import logging
6import os
7import platform
8import plistlib
9import re
10from fnmatch import fnmatchcase
11from typing import TYPE_CHECKING, Any
12
13from aiohttp import ClientError, ClientTimeout
14from music_assistant_models.enums import ContentType
15from music_assistant_models.media_items import AudioFormat
16
17from music_assistant.helpers.process import check_output
18from music_assistant.helpers.util import format_ip_for_url
19
20from .constants import AIRPLAY_BUFFER_DEPTH_DEFAULTS
21
22if TYPE_CHECKING:
23 from zeroconf.asyncio import AsyncServiceInfo
24
25 from music_assistant.mass import MusicAssistant
26
27_LOGGER = logging.getLogger(__name__)
28_COMPANION_PAIRING_DISABLED = 0x04
29_COMPANION_PAIRING_WITH_PIN = 0x4000
30# Bound the binary's `--check` probe. It normally answers instantly, but the first
31# execution of a freshly-fetched binary can stall (e.g. macOS Gatekeeper verification
32# of an unsigned download), and a wedged binary would otherwise block provider load or
33# a stream start indefinitely.
34_CLI_BINARY_CHECK_TIMEOUT = 15.0
35# Bound the /info capability probe: it runs in the discovery path, and a receiver
36# that is slow to answer must not hold up player registration.
37_INFO_PROBE_TIMEOUT = 5.0
38
39
40def convert_airplay_volume(value: float) -> int:
41 """Remap AirPlay dB volume (-30..0) to 0..100 scale."""
42 airplay_min = -30.0
43 airplay_max = 0.0
44 value = max(airplay_min, min(airplay_max, value))
45 portion = (value - airplay_min) * 100.0 / (airplay_max - airplay_min)
46 return max(0, min(100, round(portion)))
47
48
49def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]: # noqa: PLR0911
50 """Return Manufacturer and Model name from mdns info."""
51 manufacturer = info.decoded_properties.get("manufacturer")
52 model = info.decoded_properties.get("model")
53 if manufacturer and model:
54 return (manufacturer, model)
55 # try parse from am property
56 if am_property := info.decoded_properties.get("am"):
57 model = am_property
58
59 if not model:
60 model = "Unknown"
61
62 # parse apple model names
63 if model == "AudioAccessory6,1":
64 return ("Apple", "HomePod 2")
65 if model in ("AudioAccessory5,1", "AudioAccessorySingle5,1"):
66 return ("Apple", "HomePod Mini")
67 if model == "AppleTV1,1":
68 return ("Apple", "Apple TV Gen1")
69 if model == "AppleTV2,1":
70 return ("Apple", "Apple TV Gen2")
71 if model in ("AppleTV3,1", "AppleTV3,2"):
72 return ("Apple", "Apple TV Gen3")
73 if model == "AppleTV5,3":
74 return ("Apple", "Apple TV Gen4")
75 if model == "AppleTV6,2":
76 return ("Apple", "Apple TV 4K")
77 if model == "AppleTV11,1":
78 return ("Apple", "Apple TV 4K Gen2")
79 if model == "AppleTV14,1":
80 return ("Apple", "Apple TV 4K Gen3")
81 if model == "UPL-AMP":
82 return ("Ubiquiti Inc.", "UPL-AMP")
83 if "AirPort" in model:
84 return ("Apple", "AirPort Express")
85 if "AudioAccessory" in model:
86 return ("Apple", "HomePod")
87 if "AppleTV" in model:
88 model = "Apple TV"
89 manufacturer = "Apple"
90 # Detect Mac devices (Mac mini, MacBook, iMac, etc.)
91 # Model identifiers like: Mac16,11, MacBookPro18,3, iMac21,1
92 if model.startswith(("Mac", "iMac")):
93 # Parse Mac model to friendly name
94 if model.startswith("MacBookPro"):
95 return ("Apple", f"MacBook Pro ({model})")
96 if model.startswith("MacBookAir"):
97 return ("Apple", f"MacBook Air ({model})")
98 if model.startswith("MacBook"):
99 return ("Apple", f"MacBook ({model})")
100 if model.startswith("iMac"):
101 return ("Apple", f"iMac ({model})")
102 if model.startswith("Macmini"):
103 return ("Apple", f"Mac mini ({model})")
104 if model.startswith("MacPro"):
105 return ("Apple", f"Mac Pro ({model})")
106 if model.startswith("MacStudio"):
107 return ("Apple", f"Mac Studio ({model})")
108 # Generic Mac device (e.g. Mac16,11 for Mac mini M4)
109 return ("Apple", f"Mac ({model})")
110
111 return (manufacturer or "AirPlay", model)
112
113
114def parse_airplay_features(features_value: str | None) -> int:
115 """Return an AirPlay features bitmask, or zero for an invalid value."""
116 if not features_value:
117 return 0
118 try:
119 parts = features_value.split(",")
120 features = int(parts[0], 16)
121 if len(parts) > 1:
122 features |= int(parts[1], 16) << 32
123 except TypeError, ValueError:
124 return 0
125 return features
126
127
128def supports_airplay2(features_value: str | None) -> bool:
129 """
130 Check if a device advertises AirPlay 2 support in its features bitmask.
131
132 :param features_value: Raw features value from the mDNS TXT records
133 (``features`` on the _airplay service or ``ft`` on the _raop service),
134 formatted as ``0xLOW`` or ``0xLOW,0xHIGH``.
135 """
136 features = parse_airplay_features(features_value)
137 # SupportsUnifiedMediaControl (bit 38) / SupportsCoreUtilsPairingAndEncryption
138 # (bit 48): either one means the device speaks AirPlay 2. This mirrors the
139 # test the cliairplay binary uses for its automatic route selection.
140 return bool((features >> 38) & 1 or (features >> 48) & 1)
141
142
143def is_apple_device(manufacturer: str, model: str) -> bool:
144 """
145 Check if a device is a (standalone) Apple device with native AirPlay support.
146
147 Apple devices (HomePod, Apple TV) have native AirPlay support
148 and should be exposed as PlayerType.PLAYER.
149 We don't include MacBooks etc. here as they are not standalone devices
150 and may also be used for other protocols.
151 """
152 return manufacturer.lower().startswith("apple") and (
153 "homepod" in model.lower() or "apple tv" in model.lower()
154 )
155
156
157def is_macos_device(manufacturer: str, model: str) -> bool:
158 """Return whether an AirPlay device identifies as a Mac."""
159 return manufacturer.lower().startswith("apple") and model.lower().startswith(("mac", "imac"))
160
161
162def is_apple_tv(manufacturer: str, model: str) -> bool:
163 """
164 Check if a device identifies as an Apple TV (and not a HomePod).
165
166 Only Apple TVs run the tvOS dashboard app, so this narrows :func:`is_apple_device`
167 to the Apple TV family. The model strings come from :func:`get_model_info`
168 (e.g. "Apple TV 4K", "Apple TV Gen4").
169 """
170 return manufacturer.lower().startswith("apple") and "apple tv" in model.lower()
171
172
173def default_hires_enabled(manufacturer: str, model: str) -> bool:
174 """
175 Return whether 24-bit playback should be enabled by default for a device.
176
177 :param manufacturer: Device manufacturer from discovery.
178 :param model: Device model from discovery.
179 """
180 # HomePods are WiFi-only receivers on the realtime (UDP) stream, whose
181 # 24-bit audio packets regularly exceed the network MTU; on a lossy WiFi
182 # link that surfaces as intermittent crackling, so they default to 16-bit.
183 return not (manufacturer.lower().startswith("apple") and "homepod" in model.lower())
184
185
186def default_buffer_depth(manufacturer: str, model: str, fv: str | None) -> int:
187 """
188 Return the default receiver buffer depth in ms for a device, 0 for automatic.
189
190 :param manufacturer: Device manufacturer from discovery.
191 :param model: Device model from discovery.
192 :param fv: The device's _airplay fv (firmware) TXT record, when known.
193 """
194 for manufacturer_match, model_match, fv_match, depth_ms in AIRPLAY_BUFFER_DEPTH_DEFAULTS:
195 # fnmatchcase with both sides lowered: plain fnmatch only normalizes
196 # case on case-insensitive platforms, so a capitalized table row would
197 # match on macOS and silently fail on Linux.
198 if (
199 fnmatchcase(manufacturer.lower(), manufacturer_match.lower())
200 and fnmatchcase(model.lower(), model_match.lower())
201 and fnmatchcase((fv or "").lower(), fv_match.lower())
202 ):
203 return depth_ms
204 return 0
205
206
207def get_decoded_property(discovery_info: AsyncServiceInfo, key: str) -> str | None:
208 """
209 Return an mDNS TXT property value by case-insensitive key.
210
211 TXT record keys are case-insensitive (RFC 6763) and zeroconf preserves the
212 casing as advertised on the wire, which differs per device (e.g. Companion
213 services advertise ``rpFl``, MRP services ``SystemBuildVersion``).
214
215 :param discovery_info: The mDNS service info to read the property from.
216 :param key: The TXT record key to look up (any casing).
217 """
218 decoded_properties = discovery_info.decoded_properties
219 if (value := decoded_properties.get(key)) is not None:
220 return value
221 folded_key = key.casefold()
222 for prop_key, prop_value in decoded_properties.items():
223 if prop_key.casefold() == folded_key:
224 return prop_value
225 return None
226
227
228def supports_companion_pairing(discovery_info: AsyncServiceInfo | None) -> bool:
229 """Return whether a Companion service supports PIN pairing."""
230 if discovery_info is None:
231 return False
232 raw_flags = get_decoded_property(discovery_info, "rpFl")
233 if raw_flags is None:
234 return False
235 try:
236 flags = int(raw_flags, 16)
237 except TypeError, ValueError:
238 return False
239 return bool(flags & _COMPANION_PAIRING_WITH_PIN) and not bool(
240 flags & _COMPANION_PAIRING_DISABLED
241 )
242
243
244def supports_mrp_tunnel(discovery_info: AsyncServiceInfo | None) -> bool:
245 """Return whether an AirPlay service advertises tunneled MRP control."""
246 if discovery_info is None:
247 return False
248 features = parse_airplay_features(
249 discovery_info.decoded_properties.get("features")
250 or discovery_info.decoded_properties.get("ft")
251 )
252 return bool((features >> 58) & 1)
253
254
255def supports_transient_mrp(discovery_info: AsyncServiceInfo | None) -> bool:
256 """Return whether an AirPlay MRP tunnel supports transient authentication."""
257 if not supports_mrp_tunnel(discovery_info):
258 return False
259 assert discovery_info is not None
260 features = parse_airplay_features(
261 discovery_info.decoded_properties.get("features")
262 or discovery_info.decoded_properties.get("ft")
263 )
264 return bool((features >> 43) & 1 or (features >> 48) & 1)
265
266
267def supports_mrp_service(discovery_info: AsyncServiceInfo | None) -> bool:
268 """Return whether a native MRP service is usable."""
269 if discovery_info is None or discovery_info.port is None:
270 return False
271 build = get_decoded_property(discovery_info, "SystemBuildVersion") or ""
272 match = re.match(r"^(\d+)[A-Z]", build)
273 return match is None or int(match.group(1)) < 19
274
275
276async def probe_audio_formats(mass: MusicAssistant, host: str, port: int) -> int:
277 """
278 Return the audio formats an AirPlay 2 receiver advertises, as a bitmask.
279
280 Zero when the device is unreachable or publishes no format tables.
281
282 :param mass: The MusicAssistant instance.
283 :param host: Address of the receiver.
284 :param port: Port of the receiver's _airplay._tcp service.
285 """
286 # The tables live in the receiver's /info response, which is served
287 # unauthenticated, so this needs no pairing or credentials.
288 url = f"http://{format_ip_for_url(host)}:{port}/info"
289 try:
290 async with mass.http_session.get(
291 url, timeout=ClientTimeout(total=_INFO_PROBE_TIMEOUT)
292 ) as resp:
293 if resp.status != 200:
294 return 0
295 info = plistlib.loads(await resp.read())
296 except ClientError, TimeoutError, plistlib.InvalidFileException, ValueError:
297 return 0
298 return _parse_format_tables(info) if isinstance(info, dict) else 0
299
300
301async def get_cli_binary() -> str:
302 """
303 Find the cliairplay binary for the current platform.
304
305 :raises RuntimeError: If the binary cannot be found.
306 """
307 system = platform.system()
308 architecture = platform.machine()
309 binary_name = _get_cli_binary_name(system, architecture)
310 if binary_name is None:
311 msg = f"Unsupported cliairplay platform: {system.lower()}/{architecture.lower()}"
312 raise RuntimeError(msg)
313 base_path = os.path.join(os.path.dirname(__file__), "bin")
314 binary_path = os.path.join(base_path, binary_name)
315
316 try:
317 returncode, output = await check_output(
318 binary_path, "--check", timeout=_CLI_BINARY_CHECK_TIMEOUT
319 )
320 output_str = output.strip().decode()
321 if returncode == 0 and "cliairplay" in output_str and "check" in output_str:
322 return binary_path
323 except TimeoutError:
324 msg = (
325 f"{binary_name} did not respond to --check within "
326 f"{_CLI_BINARY_CHECK_TIMEOUT:.0f}s (first-run verification or a wedged binary)"
327 )
328 raise RuntimeError(msg) from None
329 except OSError:
330 pass
331
332 msg = f"Unable to locate {binary_name} for {system.lower()}/{architecture.lower()}"
333 raise RuntimeError(msg)
334
335
336def player_id_to_mac_address(player_id: str) -> str:
337 """Convert a player_id to a MAC address-like string."""
338 # the player_id is the mac address prefixed with "ap"
339 hex_str = player_id.replace("ap", "").upper()
340 return ":".join(hex_str[i : i + 2] for i in range(0, 12, 2))
341
342
343def generate_active_remote_id(mac_address: str) -> str:
344 """
345 Generate an Active-Remote ID for DACP communication.
346
347 The Active-Remote ID is used to match DACP callbacks from devices to the
348 correct stream. This function generates a consistent ID based on the
349 player_id (=macaddress, =device id), converted to uint32).
350
351 :return: Active-Remote ID as decimal string.
352 """
353 # Convert MAC address format to uint32
354 # Remove colons: "AA:BB:CC:DD:EE:FF" -> "AABBCCDDEEFF"
355 hex_str = mac_address.replace(":", "").upper()
356 # Parse as uint64 and truncate to uint32 (lower 32 bits)
357 device_id_u64 = int(hex_str, 16)
358 device_id_u32 = device_id_u64 & 0xFFFFFFFF
359 return str(device_id_u32)
360
361
362def serialize_txt_records(discovery_info: AsyncServiceInfo) -> str:
363 """
364 Serialize mDNS TXT records for cliairplay's --txt argument.
365
366 The binary receives the full _airplay._tcp TXT as a single
367 space-separated "key=value key=value ..." argument and uses it for
368 automatic route selection (RAOP vs AirPlay 2, native vs RAOP-compat,
369 PTP vs NTP). Pairs containing whitespace are skipped as the binary
370 splits the blob on spaces.
371
372 :param discovery_info: The _airplay._tcp discovery info of the device.
373 """
374 pairs: list[str] = []
375 for key, value in discovery_info.decoded_properties.items():
376 if value is None:
377 continue
378 if any(char.isspace() for char in key) or any(char.isspace() for char in value):
379 continue
380 pairs.append(f"{key}={value}")
381 return " ".join(pairs)
382
383
384def get_final_output_format(audio_format: AudioFormat) -> AudioFormat:
385 """
386 Determine the output format ffmpeg must encode to for the cliairplay binary.
387
388 The cliairplay binary always uses ALAC encoding internally.
389 """
390 return AudioFormat(
391 content_type=ContentType.ALAC,
392 sample_rate=audio_format.sample_rate,
393 bit_depth=audio_format.bit_depth,
394 channels=audio_format.channels,
395 )
396
397
398def _parse_format_tables(info: dict[str, Any]) -> int:
399 """Return the union of the format tables in a receiver's /info response."""
400 # Each stream advertises its formats either as a list of bit indices in
401 # supportedAudioFormatsExtended, or as a plain mask in the older
402 # supportedFormats. A device can use a different shape per stream.
403 extended = info.get("supportedAudioFormatsExtended")
404 legacy = info.get("supportedFormats")
405 formats = 0
406 for stream in ("audioStream", "bufferStream"):
407 if isinstance(extended, dict) and isinstance(bits := extended.get(stream), list):
408 for bit in bits:
409 if isinstance(bit, int) and 0 <= bit < 64:
410 formats |= 1 << bit
411 elif isinstance(legacy, dict) and isinstance(mask := legacy.get(stream), int):
412 formats |= mask
413 return formats
414
415
416def _get_cli_binary_name(system: str, machine: str) -> str | None:
417 """Return the cliairplay release asset name for a platform."""
418 normalized_system = system.lower().replace("darwin", "macos")
419 normalized_machine = machine.lower()
420
421 if normalized_machine in ("amd64", "x86_64"):
422 architecture = "x86_64"
423 elif normalized_machine in ("aarch64", "arm64"):
424 architecture = "arm64" if normalized_system == "macos" else "aarch64"
425 else:
426 return None
427 if normalized_system not in ("linux", "macos"):
428 return None
429 return f"cliairplay-{normalized_system}-{architecture}"
430