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