/
/
/
1"""AirPlay Player implementations."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import ipaddress
8import time
9from typing import TYPE_CHECKING, cast
10
11from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
12from music_assistant_models.enums import (
13 ConfigEntryType,
14 ContentType,
15 CrossfadeMode,
16 IdentifierType,
17 MediaType,
18 PlaybackState,
19 PlayerFeature,
20 PlayerType,
21)
22from music_assistant_models.errors import PlayerCommandFailed
23from music_assistant_models.media_items import AudioFormat
24
25from music_assistant.controllers.streams.audio import overlay_active
26from music_assistant.helpers.util import get_primary_ip_address_from_zeroconf, is_valid_mac_address
27from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
28from music_assistant.models.setup_flow import AbortFlow
29
30from . import announce
31from .constants import (
32 AIRPLAY_DISCOVERY_TYPE,
33 AIRPLAY_HIRES_AUDIO_FORMATS,
34 AIRPLAY_HIRES_SAMPLE_RATES,
35 AIRPLAY_PCM_FORMAT,
36 AIRPLAY_REJOIN_ATTEMPT_DELAYS,
37 AIRPLAY_VOLUME_ECHO_GRACE_S,
38 BASE_PLAYER_FEATURES,
39 CONF_AIRPLAY_CREDENTIALS,
40 CONF_BUFFER_DEPTH,
41 CONF_ENABLE_HIRES,
42 CONF_ENCRYPTION,
43 CONF_ENTRY_SYNC_ADJUST_AIRPLAY,
44 CONF_IGNORE_VOLUME,
45 CONF_PAIR_NOW,
46 CONF_PAIRING_PASSWORD,
47 CONF_PAIRING_PIN,
48 CONF_PASSWORD,
49 CONF_PASSWORD_INVALID,
50 CONF_RAOP_CREDENTIALS,
51 CONF_STORED_VOLUME,
52 CONF_STREAMING_MODE,
53 FALLBACK_VOLUME,
54 LEGACY_PAIRING_BIT,
55 PAIRING_PIN_FORMAT,
56 PASSWORD_BIT,
57 PIN_REQUIRED,
58 RAOP_DISCOVERY_TYPE,
59 STREAMING_MODE_AP2_COMPAT,
60 STREAMING_MODE_AP2_NTP,
61 STREAMING_MODE_AP2_PTP,
62 STREAMING_MODE_AUTO,
63 STREAMING_MODE_RAOP,
64 StreamingProtocol,
65)
66from .helpers import (
67 default_buffer_depth,
68 default_hires_enabled,
69 get_decoded_property,
70 is_apple_device,
71 is_macos_device,
72 parse_airplay_features,
73 player_id_to_mac_address,
74 supports_airplay2,
75)
76from .stream_session import AirPlayStreamSession
77
78if TYPE_CHECKING:
79 from zeroconf.asyncio import AsyncServiceInfo
80
81 from music_assistant.models.setup_flow import SetupSession
82
83 from .pairing import AirPlayPairing
84 from .provider import AirPlayProvider
85 from .stream import AirPlayStream
86
87# Docker bridge subnet, sometimes wrongly advertised via mDNS by containerized devices.
88_DOCKER_SUBNET = ipaddress.ip_network("172.16.0.0/12")
89
90
91class AirPlayPlayer(Player):
92 """Base implementation shared by all AirPlay players."""
93
94 def __init__(
95 self,
96 provider: AirPlayProvider,
97 player_id: str,
98 raop_discovery_info: AsyncServiceInfo | None,
99 airplay_discovery_info: AsyncServiceInfo | None,
100 address: str,
101 display_name: str,
102 manufacturer: str,
103 model: str,
104 initial_volume: int = FALLBACK_VOLUME,
105 ) -> None:
106 """Initialize AirPlayPlayer."""
107 self.raop_discovery_info = raop_discovery_info
108 self.airplay_discovery_info = airplay_discovery_info
109 # Audio formats the receiver advertises, learned from its /info response;
110 # zero until that lands (or when the device publishes no format tables).
111 self.advertised_audio_formats = 0
112 self._attr_enabled_by_default = not is_macos_device(manufacturer, model)
113 super().__init__(provider, player_id)
114 self.address = address
115 self.stream: AirPlayStream | None = None
116 # Serializes the two paths that can put a cliairplay process on this
117 # receiver (the native stream session and the Sendspin bridge), from the
118 # moment either decides to displace what is published until it publishes
119 # its own stream. Two processes on one receiver reset each other's RTSP
120 # channel and both sessions die. Always taken INSIDE self._lock, never
121 # around it: play_media holds self._lock across the whole session start,
122 # which takes this lock for every member.
123 self.stream_spawn_lock = asyncio.Lock()
124 self.last_command_sent = 0.0
125 self._volume_reports_ignored_until = 0.0
126 self._lock = asyncio.Lock()
127 self._transitioning = False # Set during stream replacement to ignore stale DACP messages
128 self._rejoin_task: asyncio.Task[None] | None = None
129 # Set (static) player attributes
130 self._attr_name = display_name
131 self._attr_available = True
132 mac_address = player_id_to_mac_address(player_id)
133 self._attr_device_info = DeviceInfo(
134 model=model,
135 manufacturer=manufacturer,
136 )
137 # Only add MAC address if it's valid (not 00:00:00:00:00:00)
138 if is_valid_mac_address(mac_address):
139 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
140 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
141 self._attr_device_info.add_identifier(IdentifierType.AIRPLAY_ID, player_id)
142 self._attr_volume_level = initial_volume
143 self._attr_can_group_with = {provider.instance_id}
144
145 @property
146 def protocol(self) -> StreamingProtocol:
147 """Get the streaming protocol to use/prefer for this player."""
148 # AirPlay 2 whenever the device can speak it and RAOP is not being forced;
149 # RAOP for legacy receivers (or when the RAOP streaming mode is set).
150 if self._is_airplay2_capable and self.streaming_mode != STREAMING_MODE_RAOP:
151 return StreamingProtocol.AIRPLAY2
152 return StreamingProtocol.RAOP
153
154 @property
155 def streaming_mode(self) -> str:
156 """
157 Return the effective per-player streaming mode.
158
159 Automatic unless the (advanced) streaming-mode setting pins a lane the
160 device actually offers; a stored value the device no longer advertises
161 falls back to Automatic rather than forcing an impossible route.
162 """
163 value = str(self.config.get_value(CONF_STREAMING_MODE, STREAMING_MODE_AUTO))
164 offered = {option.value for option in self.streaming_mode_options}
165 return value if value in offered else STREAMING_MODE_AUTO
166
167 @property
168 def streaming_mode_options(self) -> list[ConfigValueOption]:
169 """
170 Return the streaming-mode options this device can actually offer.
171
172 Every option is an escape from the automatic AirPlay 2 route, gated on
173 the device's own advertisements: the AirPlay 2 lanes need AirPlay 2
174 capability (PTP timing additionally needs the SupportsPTP bit), and
175 legacy RAOP needs an advertised _raop service to fall back to. A
176 RAOP-only device has no alternative lane and keeps Automatic only,
177 which hides the entry entirely. Apple receivers get every lane except
178 NTP timing â they render silence on an NTP-timed realtime stream
179 (hardware-measured). Of their lanes, the compatibility flow and
180 legacy RAOP are the escapes for networks where the PTP ports are
181 blocked; pinning PTP is an explicit choice of the normal lane.
182 """
183 options = [ConfigValueOption(STREAMING_MODE_AUTO, "Automatic (recommended)")]
184 if not self._is_airplay2_capable:
185 return options
186 apple = is_apple_device(self.device_info.manufacturer, self.device_info.model)
187 features = parse_airplay_features(self._advertised_features)
188 if (features >> 41) & 1:
189 options.append(ConfigValueOption(STREAMING_MODE_AP2_PTP, "AirPlay 2 - PTP timing"))
190 if not apple:
191 options.append(ConfigValueOption(STREAMING_MODE_AP2_NTP, "AirPlay 2 - NTP timing"))
192 options.append(
193 ConfigValueOption(STREAMING_MODE_AP2_COMPAT, "AirPlay 2 - compatibility mode")
194 )
195 if self.raop_discovery_info is not None:
196 options.append(ConfigValueOption(STREAMING_MODE_RAOP, "AirPlay 1 (RAOP)"))
197 return options
198
199 @property
200 def protocol_override(self) -> StreamingProtocol | None:
201 """
202 Return the user-forced streaming protocol, or None for automatic selection.
203
204 Only the RAOP streaming mode forces the protocol outright; the AirPlay 2
205 modes stay on the AirPlay 2 protocol and pin the flow/timing through the
206 binary's --protocol/--timing arguments instead. Otherwise the cliairplay
207 binary resolves the route itself from the mDNS TXT records (--protocol
208 auto) and the ``protocol`` property above only reflects MA's own planning
209 heuristic (timing, ports).
210 """
211 if self.streaming_mode == STREAMING_MODE_RAOP:
212 return StreamingProtocol.RAOP
213 return None
214
215 @property
216 def hires_playback_enabled(self) -> bool:
217 """Return if 24-bit hi-res playback is possible and enabled for this player."""
218 # 24-bit only works over the AirPlay 2 flow, so a device that streams RAOP
219 # (a legacy receiver, or the force-RAOP escape hatch) stays on the 16-bit
220 # base whatever it advertises.
221 return (
222 bool(self.advertised_audio_formats & AIRPLAY_HIRES_AUDIO_FORMATS)
223 and self.protocol == StreamingProtocol.AIRPLAY2
224 # the compat lane is 16-bit only, so hi-res stands down while the pin is active
225 and self.streaming_mode != STREAMING_MODE_AP2_COMPAT
226 and bool(self.config.get_value(CONF_ENABLE_HIRES, self._hires_default_enabled))
227 )
228
229 @property
230 def supported_sample_rates(self) -> list[tuple[int, int]]:
231 """Return the (sample_rate, bit_depth) pairs this player natively supports."""
232 if self.hires_playback_enabled:
233 return AIRPLAY_HIRES_SAMPLE_RATES
234 return [(AIRPLAY_PCM_FORMAT.sample_rate, AIRPLAY_PCM_FORMAT.bit_depth)]
235
236 @property
237 def needs_setup(self) -> bool:
238 """Return if the player needs setup."""
239 # A stored password satisfies password protection on its own (the binary
240 # authenticates with it directly; stored credentials are only its
241 # fallback), so the password side is fully covered by the check above.
242 if self.needs_password_setup:
243 return True
244 if self._requires_pin_pairing():
245 # Credentials for either protocol keep the player usable: the binary
246 # picks the best route for the credentials it has. Re-running the setup
247 # flow from the player settings offers replacing a stored pairing.
248 if not (
249 self.get_setup_value(CONF_AIRPLAY_CREDENTIALS)
250 or self.get_setup_value(CONF_RAOP_CREDENTIALS)
251 ):
252 return True
253 return False
254
255 @property
256 def setup_reason(self) -> str | None:
257 """Return why the player needs setup, or None when it is ready to use."""
258 if not self.needs_setup:
259 return None
260 return "password_required" if self.needs_password_setup else "pairing_required"
261
262 @property
263 def password_required(self) -> bool:
264 """Return if the device announces that it is password protected."""
265 # Two announcement forms, verified against live devices (including Apple
266 # TVs, which raise the password bit only while a password is actually
267 # set): receivers publish the password bit in sf/flags and/or the classic
268 # pw boolean. Enforcement can also exist WITHOUT any announcement (stale
269 # TXT after the password was enabled); that case is caught at connect
270 # time via password_invalid.
271 if self._get_flags() & PASSWORD_BIT:
272 return True
273 if raop_info := self.raop_discovery_info:
274 return (raop_info.decoded_properties.get("pw") or "").lower() == "true"
275 return False
276
277 @property
278 def password_invalid(self) -> bool:
279 """Return if the device rejected the stored password on its last connect."""
280 return bool(
281 self.mass.config.get_raw_player_config_value(
282 self.player_id, CONF_PASSWORD_INVALID, False
283 )
284 )
285
286 @property
287 def needs_password_setup(self) -> bool:
288 """Return if the device password still has to be entered through the setup flow."""
289 # The password is only ever entered through the setup flow, so both a
290 # device that announces password protection without one stored and a
291 # password the device rejected must send the user back into that flow.
292 if self.password_invalid:
293 return True
294 return self.password_required and not self.config.get_value(CONF_PASSWORD)
295
296 def set_password_invalid(self, invalid: bool) -> None:
297 """
298 Persist (or clear) the marker that the device rejected the stored password.
299
300 :param invalid: True when the device rejected the password, False once a
301 connect succeeded or a new password was stored.
302 """
303 if self.password_invalid == invalid:
304 # keeps a successful connect from writing the config on every stream
305 return
306 self.mass.config.set_raw_player_config_value(self.player_id, CONF_PASSWORD_INVALID, invalid)
307 # needs_setup/setup_reason are part of the player's own state inputs, so a
308 # plain update publishes the (dis)appeared setup action to the clients.
309 self.update_state()
310
311 @property
312 def requires_flow_mode(self) -> bool:
313 """Return if the player requires flow mode."""
314 return True
315
316 @property
317 def supported_features(self) -> set[PlayerFeature]:
318 """Return the supported features of this player."""
319 # PAUSE is always advertised, including while synced. This keeps the AirPlay
320 # player itself as the pause control target so pause() can decide what to do:
321 # a true pause for a single player, or a full session stop for a sync group
322 # (see pause()). If PAUSE were dropped while grouped, the players controller
323 # could fall through to a linked native player's pause (e.g. a Sonos acting as
324 # an AirPlay receiver), which only pauses the sync leader while the other
325 # members keep playing.
326 features = {*BASE_PLAYER_FEATURES, PlayerFeature.PAUSE}
327 # An announcement is mixed into the audio the player is already rendering, so
328 # the feature is only offered while there is live playback to mix into. Without
329 # it the players controller plays the announcement its own way, which leaves
330 # the device to whatever else may be streaming to it.
331 if not self.has_live_audio:
332 features.discard(PlayerFeature.PLAY_ANNOUNCEMENT)
333 return features
334
335 @property
336 def has_live_audio(self) -> bool:
337 """Return True if the player is rendering audio an announcement can mix into."""
338 if self.playback_state != PlaybackState.PLAYING:
339 return False
340 if self.stream is None or self.stream.superseded:
341 # A stream handed to a teardown stays published until its process is
342 # off the receiver, and a clip mixed into it dies with it.
343 return False
344 return self.stream.running and self.stream.connected
345
346 @property
347 def applies_announcement_volume(self) -> bool:
348 """Return True: the announcement volume is applied around the mixed clip."""
349 return True
350
351 @property
352 def can_group_with(self) -> set[str]:
353 """
354 Return player IDs this player can group with.
355
356 RAOP and AP2 players can group with other RAOP and/or AP2 players.
357 """
358 prov = cast("AirPlayProvider", self.provider)
359 return {
360 p.player_id for p in prov.get_players() if p.available and p.player_id != self.player_id
361 }
362
363 @property
364 def native_grouping_requires_own_stream(self) -> bool:
365 """Return True: members are attached to this player's own stream session."""
366 return True
367
368 @property
369 def live_session_members(self) -> list[str]:
370 """Return the id's of the players the running stream session feeds."""
371 # group membership is bookkeeping that outlives the session: a member can be
372 # dropped from the session (write failures) or never make it in (a refused
373 # late join) while still being listed as part of the group, and without a
374 # session there is nobody to render with at all
375 if self.stream and self.stream.running and self.stream.session:
376 return [x.player_id for x in self.stream.session.sync_clients]
377 return []
378
379 async def get_config_entries(self) -> list[ConfigEntry]:
380 """Return all (provider/player specific) Config Entries for the given player (if any)."""
381 # Pairing/credentials are no longer config entries: they are collected by the
382 # interactive setup flow (run_setup_flow) and stored in the player's setup_data.
383 base_entries: list[ConfigEntry] = []
384
385 # Effective RAOP state from the current (stored) streaming mode, so the
386 # RAOP-only entries show/hide consistently with it.
387 is_raop = self.protocol == StreamingProtocol.RAOP
388
389 # Streaming-mode escape hatch: a per-device pin of the protocol/timing
390 # lane for receivers whose automatic route misbehaves. Only offered
391 # when the device actually has a lane to choose (Apple receivers are
392 # always native AirPlay 2 with PTP and get no entry).
393 mode_options = self.streaming_mode_options
394 if len(mode_options) > 1:
395 base_entries.append(
396 ConfigEntry(
397 key=CONF_STREAMING_MODE,
398 type=ConfigEntryType.STRING,
399 options=mode_options,
400 default_value=STREAMING_MODE_AUTO,
401 category="protocol_generic",
402 advanced=True,
403 )
404 )
405
406 # 24-bit toggle, shown only when the device advertises 24-bit support
407 # (per-device default: see default_hires_enabled). Hidden rather than
408 # omitted when it does not: the formats are probed async after
409 # registration, and an entry absent from the registration-time config
410 # parse would drop the user's stored value until the next config save.
411 base_entries.append(
412 ConfigEntry(
413 key=CONF_ENABLE_HIRES,
414 type=ConfigEntryType.BOOLEAN,
415 default_value=self._hires_default_enabled,
416 hidden=not self.advertised_audio_formats & AIRPLAY_HIRES_AUDIO_FORMATS,
417 category="protocol_generic",
418 requires_reload=True,
419 )
420 )
421
422 # Regular AirPlay config entries
423 base_entries += [
424 CONF_ENTRY_SYNC_ADJUST_AIRPLAY,
425 ConfigEntry(
426 key=CONF_ENCRYPTION,
427 type=ConfigEntryType.BOOLEAN,
428 default_value=True,
429 hidden=not is_raop,
430 category="protocol_generic",
431 advanced=True,
432 ),
433 ConfigEntry(
434 key=CONF_PASSWORD,
435 type=ConfigEntryType.SECURE_STRING,
436 default_value=None,
437 required=False,
438 # Storage (and encryption) vehicle only: the device password is
439 # entered through the setup flow, which is also what a wrong
440 # password sends the user back to. A hidden entry keeps its stored
441 # value across config saves (the frontend never submits it).
442 hidden=True,
443 category="protocol_generic",
444 advanced=True,
445 ),
446 ConfigEntry(
447 key=CONF_IGNORE_VOLUME,
448 type=ConfigEntryType.BOOLEAN,
449 default_value=False,
450 category="protocol_generic",
451 advanced=True,
452 ),
453 # Receiver-queue depth presets. The range reaches past the standard
454 # 2 s receiver buffer because that figure is only what the binary
455 # assumes for a device that reports no window of its own, and the
456 # deepest starving devices ask for more than the assumption. The
457 # default comes from the device-family table, and Automatic resolves
458 # through that same table at stream time, so selecting it never
459 # downgrades an affected device.
460 ConfigEntry(
461 key=CONF_BUFFER_DEPTH,
462 type=ConfigEntryType.INTEGER,
463 options=[
464 ConfigValueOption(0),
465 ConfigValueOption(500),
466 ConfigValueOption(750),
467 ConfigValueOption(1000),
468 ConfigValueOption(1500),
469 ConfigValueOption(1750),
470 ConfigValueOption(2000),
471 ConfigValueOption(2500),
472 ConfigValueOption(3000),
473 ],
474 default_value=default_buffer_depth(
475 self.device_info.manufacturer or "",
476 self.device_info.model or "",
477 get_decoded_property(self.airplay_discovery_info, "fv")
478 if self.airplay_discovery_info
479 else None,
480 ),
481 category="protocol_generic",
482 advanced=True,
483 requires_reload=True,
484 ),
485 ]
486
487 return base_entries
488
489 async def run_setup_flow(self, session: SetupSession) -> None:
490 """
491 Run the interactive setup flow for this AirPlay player (streaming pairing).
492
493 :param session: The setup flow session used to interact with the user.
494 """
495 collected: dict[str, ConfigValueType] = {}
496 await self._run_streaming_pairing(session, collected)
497 await session.finish(collected)
498
499 async def stop(self) -> None:
500 """Send STOP command to player."""
501 # an explicit stop (including power-off routed as stop) is user intent:
502 # drop any pending automatic re-join
503 self.cancel_group_rejoin()
504 async with self._lock:
505 if self.stream and self.stream.session:
506 # forward stop to the entire stream session
507 await self.stream.session.stop()
508 elif cast("AirPlayProvider", self.provider).bridge_manager.stop_streaming(
509 self.player_id
510 ):
511 # Sendspin bridge active: it tears the transport down straight
512 # away and takes the player out of the Sendspin session
513 pass
514 elif self.stream and self.stream.running:
515 # Fallback: stop protocol directly
516 await self.stream.stop(force=True)
517 self.stream = None
518 self._attr_current_media = None
519 self.update_state()
520
521 async def play(self) -> None:
522 """Handle PLAY (unpause) command on the player."""
523 session = self.stream.session if self.stream and self.stream.running else None
524 if self.group_members or self.synced_to or (session and session.parked):
525 # Grouped pause parks the whole session (standby); unpausing one
526 # member cannot restart the group in sync, and a parked member is
527 # held with nothing being fed until a re-anchor - which ACTION=PLAY
528 # does not carry, so it would report playback over silence. The park
529 # outlives the group, so a player left alone by an ungroup is keyed
530 # on the park itself, not on its membership. Resume via the queue
531 # instead: play_media flushes and re-anchors every parked member at
532 # one shared instant. The queue can belong to a linked native parent
533 # (for example Sonos), so resolve it instead of using the AirPlay ID.
534 active_queue = self.mass.players.get_active_queue(self)
535 if active_queue is None:
536 raise PlayerCommandFailed(
537 f"Cannot resume AirPlay player {self.display_name} without an active queue"
538 )
539 await self.mass.player_queues.resume(active_queue.queue_id, fade_in=False)
540 return
541 async with self._lock:
542 if self.stream and self.stream.running:
543 if await self.stream.send_cli_command("ACTION=PLAY"):
544 # Resuming re-anchors playout; the binary zeroes its own
545 # re-anchor total on resume, so drop the tracked shift to
546 # keep the server and binary baselines aligned.
547 self.stream.reset_reanchor_shift()
548
549 async def pause(self) -> None:
550 """Send PAUSE command to player."""
551 if self.group_members or self.synced_to:
552 # A broadcast pause cannot keep independent member processes
553 # sample-aligned on resume. Instead the session is parked: every
554 # member stalls but keeps its connection (and remote control), and
555 # the queue's resume flushes and re-anchors over the live
556 # connections â the same coordinated warm restart as seek/next.
557 if (
558 self.stream
559 and self.stream.running
560 and self.stream.session
561 and await self.stream.session.standby()
562 ):
563 return
564 # Some member no longer has a live connection: full stop and let
565 # the queue controller resume from the saved position.
566 self.logger.debug("Sync group cannot be parked, using STOP instead of PAUSE")
567 await self.stop()
568 return
569
570 async with self._lock:
571 if not self.stream or not self.stream.running:
572 return
573 await self.stream.send_cli_command("ACTION=PAUSE")
574
575 async def play_media(self, media: PlayerMedia) -> None:
576 """Handle PLAY MEDIA on given player."""
577 # the player is being (re)purposed on purpose: drop any pending
578 # automatic re-join left over from an unexpected stream loss
579 self.cancel_group_rejoin()
580 async with self._lock:
581 if self.synced_to:
582 # this should not happen, but guard anyways
583 raise RuntimeError("Player is synced")
584 self._attr_current_media = media
585
586 sync_clients = self._get_sync_clients()
587 session_pcm_format = await self._get_session_pcm_format(sync_clients, media)
588
589 # Warm path: a live, compatible session absorbs the new media via a
590 # flush-refill in place (seek/next never pays the reconnect cost).
591 if (
592 self.stream
593 and self.stream.running
594 and self.stream.session
595 and self.stream.session.can_replace(sync_clients, session_pcm_format)
596 ):
597 self._transitioning = True
598 audio_source = self.mass.streams.get_stream(
599 media, session_pcm_format, self.player_id
600 )
601 if await self.stream.session.replace(audio_source, media):
602 self._transitioning = False
603 # A seek changes no media identity, so the identity-driven
604 # metadata callback stays silent and receivers would show
605 # a stale Now Playing position; nudge every member once
606 # the queue position has settled.
607 for member in self.stream.session.sync_clients:
608 self.mass.call_later(
609 1,
610 member.on_player_media_updated,
611 task_id=f"player_media_updated_{member.player_id}",
612 )
613 return
614 # warm replacement failed; fall through to a cold restart
615
616 # Cold path: stop any existing stream and set up from scratch
617 if self.stream and self.stream.running and self.stream.session:
618 # Set transitioning flag to ignore stale DACP messages (like prevent-playback)
619 self._transitioning = True
620 stopped_stream = self.stream
621 await self.stream.session.stop()
622 # Only drop what this call stopped: tearing a group session down
623 # awaits every member, and a bridge can publish its own stream
624 # here. Erasing that would leave the start below with nothing to
625 # displace and a live process still on the speaker.
626 if self.stream is stopped_stream:
627 self.stream = None
628
629 # select audio source
630 audio_source = self.mass.streams.get_stream(media, session_pcm_format, self.player_id)
631
632 # setup StreamSession for player (and its sync childs if any)
633 provider = cast("AirPlayProvider", self.provider)
634 stream_session = AirPlayStreamSession(
635 provider,
636 sync_clients,
637 session_pcm_format,
638 media,
639 )
640 await stream_session.start(audio_source)
641 self._transitioning = False
642
643 async def play_announcement(
644 self, announcement: PlayerMedia, volume_level: int | None = None
645 ) -> None:
646 """
647 Play an announcement natively, mixed over the audio the player is rendering.
648
649 :param announcement: Details of the announcement that needs to be played.
650 :param volume_level: Optional volume level for the announcement.
651 """
652 # The lock windows live inside the orchestration: the dispatch decision and
653 # the arming hold self._lock like play_media does, while the multi-second
654 # clip waits run outside it (see announce.py).
655 await announce.play_announcement(self, announcement, volume_level)
656
657 async def volume_set(self, volume_level: int) -> None:
658 """Send VOLUME_SET command to given player."""
659 # Record before sending: the connect-time volume push reads this attribute,
660 # so a send that suspends first would let that push send the stale level.
661 self._attr_volume_level = volume_level
662 if self.stream and self.stream.running and self.volume_muted is not True:
663 await self.stream.send_cli_command(f"VOLUME={volume_level}")
664 self.update_state()
665 # store last state in playerconfig
666 self.mass.config.set_raw_player_config_value(
667 self.player_id, CONF_STORED_VOLUME, volume_level
668 )
669
670 async def volume_mute(self, muted: bool) -> None:
671 """Handle VOLUME_MUTE command on the player."""
672 self._attr_volume_muted = muted
673 if self.stream and self.stream.running:
674 volume = 0 if muted else (self.volume_level or 0)
675 await self.stream.send_cli_command(f"VOLUME={volume}")
676 self.update_state()
677
678 async def set_members(
679 self,
680 player_ids_to_add: list[str] | None = None,
681 player_ids_to_remove: list[str] | None = None,
682 ) -> None:
683 """Handle SET_MEMBERS command on the player."""
684 async with self._lock:
685 if self.synced_to:
686 # this should not happen, but guard anyways
687 raise RuntimeError("Player is synced, cannot set members")
688 if not player_ids_to_add and not player_ids_to_remove:
689 # nothing to do
690 return
691
692 stream_session = (
693 self.stream.session
694 if self.stream and self.stream.running and self.stream.session
695 else None
696 )
697 # handle removals first
698 if player_ids_to_remove:
699 if self.player_id in player_ids_to_remove:
700 # Callers only ask for this leader alone or for the whole group at once.
701 # A partial self+subset removal would need the other requested members
702 # released here as well, instead of returning right after the leader.
703 remaining_members = [
704 member_id
705 for member_id in self._attr_group_members
706 if member_id != self.player_id and member_id not in player_ids_to_remove
707 ]
708 if stream_session and remaining_members:
709 # Members stay behind: remove only this leader client,
710 # the session continues for the remaining players
711 await stream_session.remove_client(self, reason="leader removed from group")
712 elif stream_session:
713 # The whole group is being removed, tear the session down
714 await stream_session.stop()
715 self._attr_group_members = []
716 self.update_state()
717 return
718
719 for child_player in self._get_sync_clients():
720 if child_player.player_id in player_ids_to_remove:
721 # update group_members first to prevent race conditions
722 # where a concurrent play_media could re-include this player
723 if child_player.player_id in self._attr_group_members:
724 self._attr_group_members.remove(child_player.player_id)
725 if stream_session:
726 await stream_session.remove_client(
727 child_player, reason="child removed from group"
728 )
729 elif child_player.stream and child_player.stream.running:
730 # leader's stream is no longer running but child still has
731 # an active stream - stop it directly
732 await child_player.stream.stop(force=True)
733
734 # If group leader is left alone after removals, clear the group_members list
735 if (
736 self._attr_group_members
737 and len(self._attr_group_members) == 1
738 and self.player_id in self._attr_group_members
739 ):
740 self._attr_group_members = []
741
742 # handle additions
743 for player_id in player_ids_to_add or []:
744 if player_id == self.player_id or player_id in self.group_members:
745 # nothing to do: player is already part of the group
746 continue
747 child_player_to_add: AirPlayPlayer | None = cast(
748 "AirPlayPlayer | None", self.mass.players.get_player(player_id)
749 )
750 if not child_player_to_add:
751 # should not happen, but guard against it
752 continue
753
754 # ensure the child does not have an existing stream session active
755 if child_player_to_add := cast(
756 "AirPlayPlayer | None", self.mass.players.get_player(player_id)
757 ):
758 if (
759 child_player_to_add.playback_state == PlaybackState.PAUSED
760 and child_player_to_add.stream
761 ):
762 # Stop the paused stream to avoid a deadlock situation
763 await child_player_to_add.stream.stop()
764 if (
765 child_player_to_add.stream
766 and child_player_to_add.stream.running
767 and child_player_to_add.stream.session
768 and child_player_to_add.stream.session != stream_session
769 ):
770 await child_player_to_add.stream.session.remove_client(
771 child_player_to_add, reason="moving to different session"
772 )
773
774 # add new child to the existing stream (RAOP or AirPlay2) session (if any)
775 self._attr_group_members.append(player_id)
776 if stream_session and child_player_to_add is not None:
777 # Skip add_client if the player is already streaming in this session
778 # (e.g. after a dynamic leader switch where the stream continues)
779 if child_player_to_add not in stream_session.sync_clients:
780 await stream_session.add_client(child_player_to_add)
781 elif self.active_output_protocol not in (None, "native"):
782 # Members can only be attached to this player's own stream session, which
783 # does not exist while it renders through one of its output protocols.
784 self.logger.warning(
785 "%s joined the group of %s while that player renders through another "
786 "output protocol: there is no stream session to join, so it stays silent",
787 child_player_to_add.display_name if child_player_to_add else player_id,
788 self.display_name,
789 )
790
791 # Ensure group leader includes itself in group_members when it has members
792 # This is required for the synced_to property to work correctly
793 if self._attr_group_members and self.player_id not in self._attr_group_members:
794 self._attr_group_members.insert(0, self.player_id)
795
796 # always update the state after modifying group members
797 self.update_state()
798
799 @property
800 def ignore_volume_reports(self) -> bool:
801 """Return True if the device's own volume reports must not be acted on."""
802 if self._volume_reports_ignored_until > time.time():
803 # a level we sent ourselves is still echoing back
804 return True
805 return bool(
806 self.config.get_value(CONF_IGNORE_VOLUME)
807 or self.device_info.manufacturer.lower() == "apple"
808 )
809
810 def suppress_volume_reports(self, seconds: float = AIRPLAY_VOLUME_ECHO_GRACE_S) -> None:
811 """
812 Ignore the device's own volume reports for the given time.
813
814 :param seconds: How long from now the reports are ignored; a window that is
815 already open is only ever extended.
816 """
817 self._volume_reports_ignored_until = max(
818 self._volume_reports_ignored_until, time.time() + seconds
819 )
820
821 def update_volume_from_device(self, volume: int) -> None:
822 """Update volume from device feedback."""
823 if self.ignore_volume_reports:
824 return
825
826 cur_volume = self.volume_level or 0
827 if abs(cur_volume - volume) > 1 or (time.time() - self.last_command_sent) > 3:
828 self.mass.create_task(self._adopt_device_volume(volume))
829 else:
830 self._attr_volume_level = volume
831 self.mass.config.set_raw_player_config_value(self.player_id, CONF_STORED_VOLUME, volume)
832 self.update_state()
833
834 def set_discovery_info(self, discovery_info: AsyncServiceInfo, display_name: str) -> None:
835 """Set/update the discovery info for the player."""
836 self._attr_name = display_name
837 if discovery_info.type == AIRPLAY_DISCOVERY_TYPE:
838 self.airplay_discovery_info = discovery_info
839 elif discovery_info.type == RAOP_DISCOVERY_TYPE:
840 self.raop_discovery_info = discovery_info
841 else: # guard
842 return
843 cur_address = self.address
844 prefer_ipv6 = ":" in str(self.mass.streams.publish_ip)
845 new_address = get_primary_ip_address_from_zeroconf(discovery_info, prefer_ipv6=prefer_ipv6)
846 if new_address is None:
847 # should always be set, but guard against None
848 return
849 if cur_address != new_address:
850 # Ignore mDNS updates that replace a routable address with a Docker bridge one.
851 try:
852 if (
853 cur_address
854 and ipaddress.ip_address(new_address) in _DOCKER_SUBNET
855 and ipaddress.ip_address(cur_address) not in _DOCKER_SUBNET
856 ):
857 self.logger.warning(
858 "Ignoring mDNS update from %s to Docker address %s",
859 cur_address,
860 new_address,
861 )
862 self.update_state()
863 return
864 except ValueError:
865 pass
866 self.logger.debug("Address updated from %s to %s", cur_address, new_address)
867 self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, new_address)
868 self.address = new_address
869 self.update_state()
870
871 def set_state_from_stream(
872 self,
873 state: PlaybackState | None = None,
874 elapsed_time: float | None = None,
875 stream: AirPlayStream | None = None,
876 ) -> None:
877 """
878 Set the playback state from stream (RAOP or AirPlay2).
879
880 :param state: New playback state (or None to keep current).
881 :param elapsed_time: New elapsed time (or None to keep current).
882 :param stream: The stream instance sending this update (for validation).
883 """
884 # Ignore state updates from old/stale streams
885 if stream is not None and stream != self.stream:
886 return
887 # The stream reclaims the device: an external (Companion-observed)
888 # source snapshot can leak in during a brief stream-restart window and
889 # would otherwise stick, freezing the UI on a stale "external source"
890 # view while we stream. While MA streams, the stream is the sole
891 # authority on this player's state.
892 active_source = getattr(self, "_attr_active_source", None)
893 if active_source is not None and active_source in getattr(self, "_external_source_ids", ()):
894 media = getattr(self, "_attr_current_media", None)
895 if media is not None and media.source_id == active_source:
896 self._attr_current_media = None
897 self._attr_active_source = None
898 if state is not None:
899 self._attr_playback_state = state
900 if elapsed_time is not None:
901 self._attr_elapsed_time = elapsed_time
902 self._attr_elapsed_time_last_updated = time.time()
903 self.update_state()
904
905 def get_stream_pcm_format(self, session_pcm_format: AudioFormat) -> AudioFormat:
906 """
907 Return the PCM format to feed this player's cliairplay process.
908
909 :param session_pcm_format: The PCM format of the (shared) stream session.
910 """
911 if not self.hires_playback_enabled:
912 return AIRPLAY_PCM_FORMAT
913 # 24-bit: the binary expects raw s32le input on stdin (--bitdepth 24)
914 # and truncates to 24-bit ALAC internally.
915 supported_rates = {sample_rate for sample_rate, _ in self.supported_sample_rates}
916 sample_rate = (
917 session_pcm_format.sample_rate
918 if session_pcm_format.sample_rate in supported_rates
919 else AIRPLAY_PCM_FORMAT.sample_rate
920 )
921 return AudioFormat(
922 content_type=ContentType.PCM_S32LE,
923 sample_rate=sample_rate,
924 bit_depth=24,
925 )
926
927 @property
928 def owns_volume(self) -> bool:
929 """
930 Return True if this output is the resolved owner of its own volume.
931
932 AirPlay volume is the receiver's own volume: setting it writes through to the
933 device and persists there after the session ends. It may therefore only be set
934 when no other control owns the volume of this output.
935 """
936 if not (parent_id := self.protocol_parent_id):
937 # a standalone AirPlay player has no other interface to defer to
938 return True
939 if not (parent_player := self.mass.players.get_player(parent_id)):
940 return True
941 return self._control_routes_to_self(parent_player.volume_control_for_output(self.player_id))
942
943 def release_foreign_mute_latch(self) -> None:
944 """Clear our mute latch when another control owns the mute of this output."""
945 if not self._attr_volume_muted:
946 # nothing latched, so nothing that could silence this stream
947 return
948 if not (parent_id := self.protocol_parent_id):
949 return
950 if not (parent_player := self.mass.players.get_player(parent_id)):
951 return
952 if self._control_routes_to_self(parent_player.mute_control_for_output(self.player_id)):
953 # our own mute, applied through the parent
954 return
955 # The mute belongs to a control that does not own this output (a sibling interface,
956 # the receiver itself, or nothing at all). Our mute is a latch that only an explicit
957 # unmute clears, so leaving it set would report a mute we do not own and turn the
958 # next volume command into a silent one.
959 self._attr_volume_muted = False
960 self.update_state()
961
962 async def on_config_updated(self) -> None:
963 """Handle logic when the player config is updated."""
964 await super().on_config_updated()
965 prov = cast("AirPlayProvider", self.provider)
966 await prov.bridge_manager.evaluate_bridge(self)
967
968 async def on_unload(self) -> None:
969 """Handle logic when the player is unloaded from the Player controller."""
970 await super().on_unload()
971 self.cancel_group_rejoin()
972 if self.stream:
973 # remove this player from the stream session if it is running
974 if self.stream.running and self.stream.session:
975 await self.stream.session.remove_client(self, reason="player unloaded")
976 self.stream = None
977
978 def schedule_group_rejoin(self, candidate_ids: list[str]) -> None:
979 """
980 Schedule a bounded automatic re-join of this player to its still-active group.
981
982 Used when this player's stream process died unexpectedly while it was part
983 of a playing sync group (e.g. the device rode out a network blackout): the
984 player is re-added to the group's live session through the regular
985 late-join path after a short backoff. Any user action on the player (or it
986 joining a session by other means) cancels the re-join; when the group is
987 no longer playing, its membership was changed meanwhile or the device is
988 offline, the re-join is abandoned and the player simply stays idle.
989
990 :param candidate_ids: Player ids that led or shared the group at the
991 moment the stream was lost, used to resolve the re-join target (the
992 leadership may transfer while the backoff runs).
993 """
994 self.cancel_group_rejoin()
995 self.logger.info(
996 "Scheduling automatic re-join of %s to its group after unexpected stream loss",
997 self.display_name,
998 )
999 self._rejoin_task = self.mass.create_task(self._group_rejoin_attempts(candidate_ids))
1000
1001 def cancel_group_rejoin(self) -> None:
1002 """Cancel any pending automatic group re-join attempts for this player."""
1003 rejoin_task = self._rejoin_task
1004 # never self-cancel: the re-join attempt itself flows through the same
1005 # session (re)start paths that call this to clear stale schedules. The
1006 # handle also survives such a call, so a later user action can still
1007 # cancel the retry loop between attempts.
1008 if rejoin_task is None or rejoin_task is asyncio.current_task():
1009 return
1010 self._rejoin_task = None
1011 if not rejoin_task.done():
1012 rejoin_task.cancel()
1013
1014 def on_player_media_updated(self) -> None:
1015 """Handle callback when the current media of the player is updated."""
1016 if not self.stream or not self.stream.running:
1017 return
1018 metadata = self.state.current_media
1019 if not metadata:
1020 return
1021 progress = int(metadata.corrected_elapsed_time or 0)
1022 self.mass.create_task(self.stream.send_metadata(progress, metadata))
1023
1024 async def _adopt_device_volume(self, volume: int) -> None:
1025 """
1026 Take over a level the device set itself.
1027
1028 :param volume: The level the device reported.
1029 """
1030 ignored_until = self._volume_reports_ignored_until
1031 await self.volume_set(volume)
1032 # Writing the level back is a volume command like any other and opens the echo
1033 # window, but this one only hands the device its own level: leaving the window
1034 # open would swallow the rest of a volume the user is still turning up. A longer
1035 # window opened while this was in flight (an announcement) still stands.
1036 if self._volume_reports_ignored_until <= time.time() + AIRPLAY_VOLUME_ECHO_GRACE_S:
1037 self._volume_reports_ignored_until = ignored_until
1038
1039 def _control_routes_to_self(self, control: str) -> bool:
1040 """Return True if the given (resolved) control routes to this player."""
1041 if control == self.player_id:
1042 return True
1043 # bridge players riding on this player (e.g. Sendspin-over-AirPlay) forward to us
1044 if control_player := self.mass.players.get_player(control):
1045 return control_player.underlying_player_id == self.player_id
1046 return False
1047
1048 def _get_flags(self) -> int:
1049 # Flags are either present via "sf" or "flags". Taken from pyatv.protocols.airplay.utils.
1050 # We combine flags from both RAOP and AirPlay discovery services because
1051 # LEGACY_PAIRING_BIT (0x200) is typically only in the RAOP service sf field
1052 # (e.g. Apple TV HD), while PIN_REQUIRED (0x8) may only appear in the AirPlay
1053 # service sf/flags field. Using only one source misses the pairing requirement.
1054 flags = 0
1055 for discovery_info in filter(None, [self.raop_discovery_info, self.airplay_discovery_info]):
1056 raw = (
1057 discovery_info.properties.get(b"sf")
1058 or discovery_info.properties.get(b"flags")
1059 or b"0x0"
1060 )
1061 with contextlib.suppress(ValueError, TypeError):
1062 flags |= int(raw, 16)
1063 return flags
1064
1065 def _requires_pin_pairing(self) -> bool:
1066 """
1067 Check if this device requires pairing.
1068
1069 Adapted from pyatv.protocols.airplay.utils.get_pairing_requirement.
1070 """
1071 return bool(self._get_flags() & (LEGACY_PAIRING_BIT | PIN_REQUIRED))
1072
1073 def _get_credentials_key(self, protocol: StreamingProtocol) -> str:
1074 """Get the config key for credentials for given protocol."""
1075 if protocol == StreamingProtocol.RAOP:
1076 return CONF_RAOP_CREDENTIALS
1077 return CONF_AIRPLAY_CREDENTIALS
1078
1079 @property
1080 def _advertised_features(self) -> str | None:
1081 """Return the AirPlay features bitmask the device advertises via mDNS."""
1082 # Prefer the _airplay service's ``features``, falling back to the _raop
1083 # service's ``ft`` when the former is absent (some devices only populate one).
1084 features: str | None = None
1085 if self.airplay_discovery_info:
1086 features = self.airplay_discovery_info.decoded_properties.get(
1087 "features"
1088 ) or self.airplay_discovery_info.decoded_properties.get("ft")
1089 if not features and self.raop_discovery_info:
1090 features = self.raop_discovery_info.decoded_properties.get("ft")
1091 return features
1092
1093 @property
1094 def _is_airplay2_capable(self) -> bool:
1095 """
1096 Return whether this device can stream over AirPlay 2.
1097
1098 Mirrors the feature-bit test the cliairplay binary uses for its own route
1099 selection: a device is AirPlay 2 capable when it exposes the _airplay
1100 service and either advertises the AirPlay 2 feature bits or offers no RAOP
1101 fallback at all (i.e. it is a pure AirPlay 2 receiver).
1102 """
1103 if not self.airplay_discovery_info:
1104 return False
1105 return supports_airplay2(self._advertised_features) or not self.raop_discovery_info
1106
1107 async def _run_streaming_pairing(
1108 self, session: SetupSession, collected: dict[str, ConfigValueType]
1109 ) -> None:
1110 """
1111 Pair the streaming protocol (RAOP or AirPlay 2) and collect the device password.
1112
1113 The two are evaluated independently: a device that is already paired can
1114 still be missing its password (or have had it rejected), which is exactly
1115 the state a receiver ends up in when it gains password protection after
1116 it was set up.
1117
1118 :param session: The setup flow session used to interact with the user.
1119 :param collected: The values collected so far; updated in place.
1120 """
1121 password_collected = await self._run_protocol_pairing(session, collected)
1122 if not password_collected and self.needs_password_setup:
1123 await self._ask_device_password(session)
1124
1125 async def _run_protocol_pairing(
1126 self, session: SetupSession, collected: dict[str, ConfigValueType]
1127 ) -> bool:
1128 """
1129 Pair the streaming protocol (RAOP or AirPlay 2), unless already paired.
1130
1131 When the device requires pairing this runs it, re-offering it as a skippable
1132 step when credentials are already stored (so a re-launched flow can replace a
1133 stale pairing). When the device requires no pairing, any leftover credentials
1134 are cleared: they would keep forcing the pair-verify route, which some
1135 receivers (e.g. HomePods after their password was removed) accept while
1136 refusing to actually output audio. The obtained credentials are added to
1137 ``collected`` under the protocol-specific key.
1138
1139 :param session: The setup flow session used to interact with the user.
1140 :param collected: The values collected so far; updated in place.
1141 :return: Whether the device password was collected as part of the pairing.
1142 """
1143 pin_pairing = self._requires_pin_pairing()
1144 # a password only replaces PIN pairing on the native AirPlay 2 flow
1145 password_pairing = self.password_required and self.protocol == StreamingProtocol.AIRPLAY2
1146 if not (pin_pairing or password_pairing):
1147 for cred_key in (CONF_AIRPLAY_CREDENTIALS, CONF_RAOP_CREDENTIALS):
1148 if self.get_setup_value(cred_key) is not None:
1149 collected[cred_key] = None
1150 return False
1151 already_paired = bool(
1152 self.get_setup_value(CONF_AIRPLAY_CREDENTIALS)
1153 or self.get_setup_value(CONF_RAOP_CREDENTIALS)
1154 )
1155 if already_paired and not await self._offer_optional_pairing(
1156 session, "streaming_repair_offer"
1157 ):
1158 return False
1159
1160 protocol = self.protocol
1161 cred_key = self._get_credentials_key(protocol)
1162 if pin_pairing:
1163 step_id, field_key, field_type, field_format = (
1164 "pair_pin",
1165 CONF_PAIRING_PIN,
1166 ConfigEntryType.PAIRING_CODE,
1167 PAIRING_PIN_FORMAT,
1168 )
1169 else:
1170 step_id, field_key, field_type, field_format = (
1171 "pair_password",
1172 CONF_PAIRING_PASSWORD,
1173 ConfigEntryType.SECURE_STRING,
1174 None,
1175 )
1176
1177 errors: dict[str, str] | None = None
1178 while True:
1179 # Each attempt uses a fresh session: finish_pairing() closes the live
1180 # subprocess/session on completion, so a rejected PIN needs a new one
1181 # (and the device re-shows its PIN).
1182 pairing = await self._prepare_streaming_pairing(protocol, pin_pairing=pin_pairing)
1183 try:
1184 values = await session.form(
1185 [
1186 ConfigEntry(
1187 key=field_key,
1188 type=field_type,
1189 required=True,
1190 category="protocol_generic",
1191 format=field_format,
1192 )
1193 ],
1194 step_id=step_id,
1195 errors=errors,
1196 )
1197 entered_value = str(values[field_key])
1198 credentials = await pairing.finish_pairing(pin=entered_value)
1199 except PlayerCommandFailed as err:
1200 # leave a default-level trace: the flow swallows the error into
1201 # the re-served form, which support logs otherwise never show
1202 self.logger.warning("Pairing with %s failed: %s", self.display_name, err)
1203 errors = {"base": err.translation_key or str(err)}
1204 continue
1205 finally:
1206 # tears down the subprocess on retry, success and abort (cancellation)
1207 await pairing.close()
1208 collected[cred_key] = credentials
1209 if password_pairing:
1210 # The device password authenticates every later stream too (the
1211 # binary's transient leg), so keep it next to the credentials
1212 # instead of discarding it with the setup form.
1213 self._store_device_password(entered_value)
1214 return password_pairing
1215
1216 async def _ask_device_password(self, session: SetupSession) -> None:
1217 """
1218 Ask for the device password and store it, without attempting any pairing.
1219
1220 Covers the devices that have no pairing to do: a legacy RAOP receiver, and
1221 an already paired device whose password is missing or was rejected. There
1222 is no live session to validate the entry against, so a wrong password only
1223 surfaces on the next connect - which marks the player as needing setup again.
1224
1225 :param session: The setup flow session used to interact with the user.
1226 """
1227 values = await session.form(
1228 [
1229 ConfigEntry(
1230 key=CONF_PAIRING_PASSWORD,
1231 type=ConfigEntryType.SECURE_STRING,
1232 required=True,
1233 category="protocol_generic",
1234 )
1235 ],
1236 step_id="pair_password",
1237 )
1238 self._store_device_password(str(values[CONF_PAIRING_PASSWORD]))
1239
1240 async def _offer_optional_pairing(self, session: SetupSession, step_id: str) -> bool:
1241 """
1242 Ask whether to run the offered (optional) pairing now.
1243
1244 :param session: The setup flow session used to interact with the user.
1245 :param step_id: The (i18n) step id describing the offered pairing.
1246 """
1247 values = await session.form(
1248 [
1249 ConfigEntry(
1250 key=CONF_PAIR_NOW,
1251 type=ConfigEntryType.BOOLEAN,
1252 default_value=False,
1253 category="protocol_generic",
1254 )
1255 ],
1256 step_id=step_id,
1257 )
1258 return bool(values[CONF_PAIR_NOW])
1259
1260 async def _prepare_streaming_pairing(
1261 self, protocol: StreamingProtocol, *, pin_pairing: bool
1262 ) -> AirPlayPairing:
1263 """
1264 Build and start a streaming pairing session (the device shows its PIN).
1265
1266 A failure here cannot be recovered by re-prompting the user, so it aborts the
1267 flow; a partially started session is torn down first.
1268
1269 :param protocol: The streaming protocol to pair (RAOP or AirPlay 2).
1270 :param pin_pairing: Whether the device shows a PIN the user must enter.
1271 """
1272 pairing: AirPlayPairing | None = None
1273 started = False
1274 try:
1275 pairing = self._build_streaming_pairing(protocol)
1276 await pairing.start_pairing_session()
1277 if pin_pairing:
1278 await pairing.start_pin_pairing()
1279 started = True
1280 except Exception as err:
1281 # a failure starting the session (device unreachable, binary/system
1282 # issue, ...) cannot be fixed by re-prompting, so abort with a clear
1283 # reason instead of letting it surface as a generic internal error
1284 self.logger.warning("Could not start AirPlay pairing session: %s", err)
1285 raise AbortFlow("pairing_failed") from err
1286 finally:
1287 if not started and pairing is not None:
1288 await pairing.close()
1289 assert pairing is not None # reached only when started, i.e. a live session
1290 return pairing
1291
1292 def _build_streaming_pairing(self, protocol: StreamingProtocol) -> AirPlayPairing:
1293 """
1294 Build an AirPlayPairing for the given streaming protocol.
1295
1296 :param protocol: The streaming protocol to pair (RAOP or AirPlay 2).
1297 """
1298 from .pairing import AirPlayPairing # noqa: PLC0415
1299
1300 # For Apple devices pairing always happens on the AirPlay port (7000) even
1301 # when streaming will use RAOP; the RAOP port (5000) is only for streaming.
1302 port: int | None = None
1303 if self.airplay_discovery_info:
1304 port = self.airplay_discovery_info.port or 7000
1305 elif self.raop_discovery_info:
1306 port = self.raop_discovery_info.port or 5000
1307 provider = cast("AirPlayProvider", self.provider)
1308 device_id = provider.dacp_id
1309 pairing_address = self.address
1310 if protocol == StreamingProtocol.AIRPLAY2 and not isinstance(
1311 ipaddress.ip_address(pairing_address), ipaddress.IPv4Address
1312 ):
1313 if self.airplay_discovery_info:
1314 discovered_address = get_primary_ip_address_from_zeroconf(
1315 self.airplay_discovery_info
1316 )
1317 if discovered_address and isinstance(
1318 ipaddress.ip_address(discovered_address), ipaddress.IPv4Address
1319 ):
1320 pairing_address = discovered_address
1321 if not isinstance(ipaddress.ip_address(pairing_address), ipaddress.IPv4Address):
1322 raise PlayerCommandFailed("AirPlay pairing requires an IPv4 device address")
1323 return AirPlayPairing(
1324 address=pairing_address,
1325 name=self.display_name,
1326 protocol=protocol,
1327 logger=self.logger,
1328 port=port,
1329 device_id=device_id,
1330 )
1331
1332 async def _get_session_pcm_format(
1333 self, sync_clients: list[AirPlayPlayer], media: PlayerMedia
1334 ) -> AudioFormat:
1335 """
1336 Select the shared PCM format for a new stream session.
1337
1338 :param sync_clients: All players that will take part in the session.
1339 :param media: The media that is about to be played.
1340 """
1341 queue = self.mass.player_queues.get(media.source_id) if media.source_id else None
1342 queue_item = (
1343 self.mass.player_queues.get_item(media.source_id, media.queue_item_id)
1344 if media.source_id and media.queue_item_id
1345 else None
1346 )
1347 streamdetails = queue_item.streamdetails if queue_item else None
1348 crossfade_enabled = bool(
1349 queue
1350 and media.media_type == MediaType.TRACK
1351 and self.mass.streams.get_crossfade_mode(queue) != CrossfadeMode.DISABLED
1352 )
1353 return await self.mass.streams.audio.select_flow_pcm_format(
1354 self,
1355 start_streamdetails=streamdetails,
1356 crossfade_enabled=crossfade_enabled,
1357 overlay_active=bool(queue and overlay_active(queue)),
1358 fallback_sample_rate=AIRPLAY_PCM_FORMAT.sample_rate,
1359 output_players=sync_clients,
1360 )
1361
1362 def _get_sync_clients(self) -> list[AirPlayPlayer]:
1363 """Get all sync clients for a player."""
1364 sync_clients: list[AirPlayPlayer] = []
1365 # we need to return the player itself too
1366 group_child_ids = {self.player_id}
1367 group_child_ids.update(self.group_members)
1368 for child_id in group_child_ids:
1369 if client := cast("AirPlayPlayer | None", self.mass.players.get_player(child_id)):
1370 sync_clients.append(client)
1371 return sync_clients
1372
1373 async def _group_rejoin_attempts(self, candidate_ids: list[str]) -> None:
1374 """Re-join this player to its group's live session after a bounded backoff."""
1375 max_attempts = len(AIRPLAY_REJOIN_ATTEMPT_DELAYS)
1376 for attempt, delay in enumerate(AIRPLAY_REJOIN_ATTEMPT_DELAYS, start=1):
1377 await asyncio.sleep(delay)
1378 if (
1379 self.group_members
1380 or (self.stream and self.stream.running)
1381 or self.playback_state != PlaybackState.IDLE
1382 # synced into a group outside the original one = deliberate regroup.
1383 # Still pointing at an original candidate is fine: a static group
1384 # keeps the sync membership while only the session lost this player.
1385 or (self.synced_to and self.synced_to not in candidate_ids)
1386 ):
1387 # the player was grouped or repurposed by other means meanwhile
1388 self.logger.debug(
1389 "Automatic group re-join for %s cancelled: player is active again",
1390 self.display_name,
1391 )
1392 return
1393 if not self.available:
1394 # the device is offline: an attempt cannot succeed and the user
1395 # may well have switched it off on purpose
1396 self.logger.debug(
1397 "Automatic group re-join for %s cancelled: player is unavailable",
1398 self.display_name,
1399 )
1400 return
1401 target = self._resolve_rejoin_target(candidate_ids)
1402 if target is None:
1403 # the group may be between sessions (e.g. a track change); keep
1404 # trying until the attempts run out
1405 self.logger.debug(
1406 "Automatic group re-join attempt %d/%d for %s: no playing group found",
1407 attempt,
1408 max_attempts,
1409 self.display_name,
1410 )
1411 continue
1412 # When the sync membership survived the stream loss (a static group,
1413 # where membership is configuration), only the running session needs
1414 # healing; a group command would no-op on the existing membership.
1415 heal_session = (
1416 target.stream.session
1417 if self.player_id in target.group_members and target.stream is not None
1418 else None
1419 )
1420 try:
1421 if heal_session is not None:
1422 await heal_session.add_client(self)
1423 else:
1424 # Join through the target's own set_members: both ends are
1425 # players of this provider, so the join never needs the
1426 # visible-player translations of the controller's grouping
1427 # pipeline - and that pipeline's capability gate reflects
1428 # grouping state that is in flux right after a stream loss,
1429 # so it may silently refuse an internal re-join.
1430 await target.set_members(player_ids_to_add=[self.player_id])
1431 except Exception as err:
1432 self.logger.warning(
1433 "Automatic re-join of %s to group of %s failed (attempt %d/%d): %s",
1434 self.display_name,
1435 target.display_name,
1436 attempt,
1437 max_attempts,
1438 err,
1439 )
1440 continue
1441 # A late-join can also fail without raising (the player then holds
1442 # group membership without a live stream), so verify the session
1443 # actually carries this player before declaring success.
1444 if (
1445 self.stream
1446 and self.stream.running
1447 and self.stream.session
1448 and self in self.stream.session.sync_clients
1449 ):
1450 self.logger.info(
1451 "Automatically re-joined %s to the group of %s after stream loss",
1452 self.display_name,
1453 target.display_name,
1454 )
1455 return
1456 self.logger.warning(
1457 "Automatic re-join of %s did not produce a running stream (attempt %d/%d)",
1458 self.display_name,
1459 attempt,
1460 max_attempts,
1461 )
1462 if heal_session is None:
1463 # undo the group membership this attempt created so a retry (or
1464 # a manual regroup) starts from a clean join
1465 try:
1466 await target.set_members(player_ids_to_remove=[self.player_id])
1467 except Exception as err:
1468 # a failed undo leaves the membership for the next attempt,
1469 # which then heals the session instead of joining anew
1470 self.logger.debug(
1471 "Undo of failed re-join attempt for %s failed: %s",
1472 self.display_name,
1473 err,
1474 )
1475 self.logger.warning(
1476 "Giving up on automatic group re-join for %s after %d attempt(s); "
1477 "the player stays idle",
1478 self.display_name,
1479 max_attempts,
1480 )
1481
1482 def _resolve_rejoin_target(self, candidate_ids: list[str]) -> AirPlayPlayer | None:
1483 """Resolve which player now carries the group's actively playing session."""
1484 for candidate_id in candidate_ids:
1485 candidate = self.mass.players.get_player(candidate_id)
1486 if candidate is None or candidate is self:
1487 continue
1488 if not isinstance(candidate, AirPlayPlayer):
1489 continue
1490 if candidate.synced_to:
1491 # the candidate was absorbed into another group since the loss
1492 # (user intent): never follow the old group's players elsewhere.
1493 # A leadership transfer inside the original group is still found:
1494 # the promoted member is itself one of the candidates.
1495 continue
1496 if not candidate.available:
1497 continue
1498 # only a PLAYING session can absorb a late joiner: a parked (paused)
1499 # session has no live timeline to anchor against
1500 if candidate.playback_state != PlaybackState.PLAYING:
1501 continue
1502 if not (candidate.stream and candidate.stream.running and candidate.stream.session):
1503 continue
1504 return candidate
1505 return None
1506
1507 def _store_device_password(self, password: str) -> None:
1508 """
1509 Persist a device password so every later stream can authenticate with it.
1510
1511 :param password: The plaintext password entered by the user.
1512 """
1513 self.mass.config.set_raw_player_config_value(
1514 self.player_id, CONF_PASSWORD, self.mass.config.encrypt_string(password)
1515 )
1516 # a freshly entered password deserves a clean slate: the reject marker
1517 # would otherwise keep the player in "needs setup" until the next connect
1518 self.set_password_invalid(False)
1519
1520 @property
1521 def _hires_default_enabled(self) -> bool:
1522 """Return the per-device default for the 24-bit toggle."""
1523 return default_hires_enabled(
1524 self.device_info.manufacturer or "", self.device_info.model or ""
1525 )
1526
1527
1528class GenericAirPlayPlayer(AirPlayPlayer):
1529 """AirPlay protocol endpoint without independent device control."""
1530
1531 _attr_type = PlayerType.PROTOCOL
1532