/
/
/
1"""Unit tests for AirPlay player."""
2
3import asyncio
4import logging
5import time
6from typing import cast
7from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
8
9import pytest
10from music_assistant_models.constants import PLAYER_CONTROL_NATIVE
11from music_assistant_models.enums import (
12 ConfigEntryType,
13 ContentType,
14 CrossfadeMode,
15 MediaType,
16 PlaybackState,
17 PlayerFeature,
18 VolumeNormalizationMode,
19)
20from music_assistant_models.errors import PlayerCommandFailed
21from music_assistant_models.media_items import AudioFormat
22
23from music_assistant.constants import CONF_SYNC_ADJUST
24from music_assistant.controllers.streams.audio import StreamsAudio
25from music_assistant.providers.airplay.constants import (
26 AIRPLAY_PCM_FORMAT,
27 CONF_AIRPLAY_CREDENTIALS,
28 CONF_ENCRYPTION,
29 CONF_FORCE_RAOP,
30 CONF_IGNORE_VOLUME,
31 CONF_PASSWORD,
32 CONF_PASSWORD_INVALID,
33 CONF_RAOP_CREDENTIALS,
34 CONF_STORED_VOLUME,
35 StreamingProtocol,
36)
37from music_assistant.providers.airplay.player import AirPlayPlayer
38from music_assistant.providers.airplay.provider import AirPlayProvider
39from music_assistant.providers.airplay.stream_session import AirPlayStreamSession
40
41# _airplay._tcp features bitmask with the AirPlay 2 feature bits set (bit 38/48).
42AP2_FEATURES = "0x4A7FDFD5,0x3C177FDE"
43# audioFormat bits as advertised in a receiver's /info format tables.
44ALAC_44100_16 = 1 << 18
45ALAC_44100_24 = 1 << 19
46ALAC_48000_24 = 1 << 21
47
48
49def _stub_raw_config(provider: MagicMock, stored: dict[str, object] | None = None) -> None:
50 """Serve raw player config values from a dict instead of an (always truthy) mock."""
51 values = stored if stored is not None else {}
52 provider.mass.config.get_raw_player_config_value.side_effect = (
53 lambda _player_id, key, default=None: values.get(key, default)
54 )
55 provider.mass.config.set_raw_player_config_value.side_effect = lambda _player_id, key, value: (
56 values.__setitem__(key, value)
57 )
58
59
60@pytest.fixture
61def airplay_player() -> AirPlayPlayer:
62 """Create a basic AirPlayPlayer with mock defaults."""
63 provider = MagicMock()
64 _stub_raw_config(provider)
65 return AirPlayPlayer(
66 provider=provider,
67 player_id="test_player",
68 display_name="Test Player",
69 address="127.0.0.1",
70 manufacturer="Test Manufacturer",
71 model="Test Model",
72 raop_discovery_info=None,
73 airplay_discovery_info=None,
74 )
75
76
77@pytest.mark.parametrize(
78 ("manufacturer", "model", "expected"),
79 [
80 ("Apple", "MacBookPro18,3", False),
81 ("Apple Inc.", "MacBook Air (MacBookAir10,1)", False),
82 ("Apple", "iMac (iMac21,1)", False),
83 ("Apple", "Mac mini (Mac16,11)", False),
84 ("Apple", "Mac Pro (MacPro7,1)", False),
85 ("Apple", "Mac Studio (Mac14,13)", False),
86 ("Apple", "HomePod Mini", True),
87 ("Apple", "Apple TV 4K", True),
88 ("Acme", "Mac-compatible receiver", True),
89 ],
90)
91def test_macos_devices_are_disabled_by_default(
92 manufacturer: str, model: str, expected: bool
93) -> None:
94 """Macs are disabled by default without affecting other AirPlay receivers."""
95 provider = MagicMock()
96 player = AirPlayPlayer(
97 provider=provider,
98 player_id="test_player",
99 display_name="Test Player",
100 address="127.0.0.1",
101 manufacturer=manufacturer,
102 model=model,
103 raop_discovery_info=None,
104 airplay_discovery_info=None,
105 )
106
107 assert player.enabled_by_default is expected
108 assert provider.mass.config.create_default_player_config.call_args.args[-1] is expected
109
110
111@pytest.mark.parametrize(
112 ("aiplay_properties", "raop_properties", "expected"),
113 [
114 ({b"flags": b"0x200"}, None, True),
115 ({b"sf": b"0x201"}, None, True),
116 ({b"flags": b"0x4"}, None, False),
117 ({b"sf": b"0x8"}, None, True),
118 ({b"flags": b"0x9"}, None, True),
119 (None, {b"flags": "0x200"}, True),
120 (None, {b"sf": b"0x201"}, True),
121 (None, {b"flags": b"0x4"}, False),
122 (None, {b"sf": b"0x8"}, True),
123 (None, {b"flags": b"0x9"}, True),
124 # Combined flags across discovery records should be OR-ed.
125 ({b"sf": b"0x8"}, {b"sf": b"0x200"}, True),
126 ({b"sf": b"0x200"}, {b"sf": b"0x8"}, True),
127 ({b"flags": b"0x4"}, {b"flags": b"0x0"}, False),
128 ({}, {}, False),
129 ],
130)
131def test_requires_pin_pairing(
132 airplay_player: AirPlayPlayer,
133 aiplay_properties: dict[bytes, bytes] | None,
134 raop_properties: dict[bytes, bytes] | None,
135 expected: bool,
136) -> None:
137 """Test the _requires_pairing method of AirPlayPlayer."""
138 if aiplay_properties is not None:
139 aiplay_discovery_info = MagicMock()
140 aiplay_discovery_info.properties = aiplay_properties
141 airplay_player.airplay_discovery_info = aiplay_discovery_info
142 if raop_properties is not None:
143 raop_discovery_info = MagicMock()
144 raop_discovery_info.properties = raop_properties
145 airplay_player.raop_discovery_info = raop_discovery_info
146 assert airplay_player._requires_pin_pairing() == expected
147
148
149@pytest.mark.parametrize(
150 ("aiplay_properties", "raop_properties", "expected"),
151 [
152 ({b"flags": b"0x80"}, None, True),
153 ({b"sf": b"0x81"}, None, True),
154 ({b"flags": b"0x4"}, None, False),
155 ({b"sf": b"0x80"}, None, True),
156 ({b"flags": b"0x90"}, None, True),
157 ({b"flags": b"0x1000"}, None, True),
158 (None, {b"flags": "0x80"}, True),
159 (None, {b"sf": b"0x81"}, True),
160 (None, {b"flags": b"0x4"}, False),
161 (None, {b"sf": b"0x80"}, True),
162 (None, {b"flags": b"0x90"}, True),
163 ({}, {}, False),
164 ],
165)
166def test_password_required(
167 airplay_player: AirPlayPlayer,
168 aiplay_properties: dict[bytes, bytes] | None,
169 raop_properties: dict[bytes, bytes] | None,
170 expected: bool,
171) -> None:
172 """Test the flags-based password announcements (non-Apple-TV model)."""
173 if aiplay_properties is not None:
174 aiplay_discovery_info = MagicMock()
175 aiplay_discovery_info.properties = aiplay_properties
176 aiplay_discovery_info.decoded_properties = {}
177 airplay_player.airplay_discovery_info = aiplay_discovery_info
178 if raop_properties is not None:
179 raop_discovery_info = MagicMock()
180 raop_discovery_info.properties = raop_properties
181 raop_discovery_info.decoded_properties = {}
182 airplay_player.raop_discovery_info = raop_discovery_info
183 assert airplay_player.password_required == expected
184
185
186def test_build_streaming_pairing_uses_discovered_ipv4_address() -> None:
187 """HAP pairing falls back to a discovered IPv4 address when playback uses IPv6."""
188 provider = MagicMock()
189 provider.dacp_id = "test_dacp"
190 airplay_info = MagicMock()
191 airplay_info.properties = {b"flags": b"0x80"}
192 airplay_info.port = 7000
193 player = AirPlayPlayer(
194 provider=provider,
195 player_id="test_player",
196 display_name="Test Player",
197 address="2001:db8::10",
198 manufacturer="Apple",
199 model="AppleTV",
200 raop_discovery_info=None,
201 airplay_discovery_info=airplay_info,
202 )
203 pairing_instance = MagicMock()
204
205 with (
206 patch(
207 "music_assistant.providers.airplay.player.get_primary_ip_address_from_zeroconf",
208 return_value="192.168.1.50",
209 ),
210 patch(
211 "music_assistant.providers.airplay.pairing.AirPlayPairing",
212 return_value=pairing_instance,
213 ) as pairing_cls,
214 ):
215 result = player._build_streaming_pairing(StreamingProtocol.AIRPLAY2)
216
217 assert result is pairing_instance
218 assert pairing_cls.call_args.kwargs["address"] == "192.168.1.50"
219
220
221def test_build_streaming_pairing_fails_without_ipv4_address() -> None:
222 """HAP pairing reports an actionable error when discovery has no IPv4 address."""
223 provider = MagicMock()
224 provider.dacp_id = "test_dacp"
225 airplay_info = MagicMock()
226 airplay_info.properties = {b"flags": b"0x80"}
227 airplay_info.port = 7000
228 player = AirPlayPlayer(
229 provider=provider,
230 player_id="test_player",
231 display_name="Test Player",
232 address="2001:db8::10",
233 manufacturer="Apple",
234 model="AppleTV",
235 raop_discovery_info=None,
236 airplay_discovery_info=airplay_info,
237 )
238
239 with (
240 patch(
241 "music_assistant.providers.airplay.player.get_primary_ip_address_from_zeroconf",
242 return_value="2001:db8::20",
243 ),
244 pytest.raises(PlayerCommandFailed, match="requires an IPv4"),
245 ):
246 player._build_streaming_pairing(StreamingProtocol.AIRPLAY2)
247
248
249@pytest.mark.asyncio
250async def test_config_entries_include_ignore_volume(airplay_player: AirPlayPlayer) -> None:
251 """The ignore_volume setting must be offered in the player config entries."""
252 entries = await airplay_player.get_config_entries()
253 assert any(entry.key == CONF_IGNORE_VOLUME for entry in entries)
254
255
256@pytest.mark.asyncio
257async def test_config_entries_preserve_raop_encryption_setting(
258 airplay_player: AirPlayPlayer,
259) -> None:
260 """RAOP keeps its advanced encryption toggle with the secure default enabled."""
261 entries = await airplay_player.get_config_entries()
262 entry = next(entry for entry in entries if entry.key == CONF_ENCRYPTION)
263
264 assert entry.default_value is True
265 assert entry.hidden is False
266 assert entry.advanced is True
267
268
269@pytest.mark.asyncio
270async def test_config_entries_sync_adjust_is_non_advanced(airplay_player: AirPlayPlayer) -> None:
271 """AirPlay offers sync_adjust as a discoverable (non-advanced) setting."""
272 entries = await airplay_player.get_config_entries()
273 entry = next((entry for entry in entries if entry.key == CONF_SYNC_ADJUST), None)
274 assert entry is not None
275 # non-advanced so users can find it: it is the primary control for compensating
276 # a device wired to a TV / AV receiver / amplifier that adds its own audio delay
277 assert entry.advanced is False
278
279
280def _set_discovery_info(
281 player: AirPlayPlayer,
282 *,
283 raop: bool,
284 airplay: bool,
285 airplay_features: str | None = None,
286) -> None:
287 """
288 Attach discovery mocks so the device advertises the given protocols.
289
290 :param airplay_features: When set, the _airplay service advertises this
291 ``features`` bitmask (e.g. to mark the device AirPlay 2 capable).
292 """
293 if raop:
294 raop_info = MagicMock()
295 raop_info.properties = {}
296 raop_info.decoded_properties = {}
297 player.raop_discovery_info = raop_info
298 else:
299 player.raop_discovery_info = None
300 if airplay:
301 airplay_info = MagicMock()
302 airplay_info.properties = {}
303 airplay_info.decoded_properties = {"features": airplay_features} if airplay_features else {}
304 player.airplay_discovery_info = airplay_info
305 else:
306 player.airplay_discovery_info = None
307
308
309def _make_apple_player() -> AirPlayPlayer:
310 """Create an AirPlayPlayer that identifies as a genuine Apple device."""
311 return AirPlayPlayer(
312 provider=MagicMock(),
313 player_id="test_player",
314 display_name="Test Apple TV",
315 address="127.0.0.1",
316 manufacturer="Apple",
317 model="Apple TV 4K",
318 raop_discovery_info=None,
319 airplay_discovery_info=None,
320 )
321
322
323# --- Force-RAOP escape hatch: toggle visibility ---
324
325
326@pytest.mark.asyncio
327async def test_force_raop_toggle_offered_for_non_apple_airplay2(
328 airplay_player: AirPlayPlayer,
329) -> None:
330 """A non-Apple AirPlay 2 device that also speaks RAOP gets the force-RAOP escape hatch."""
331 _set_discovery_info(airplay_player, raop=True, airplay=True, airplay_features=AP2_FEATURES)
332 entries = await airplay_player.get_config_entries()
333 entry = next((entry for entry in entries if entry.key == CONF_FORCE_RAOP), None)
334 assert entry is not None
335 assert entry.type == ConfigEntryType.BOOLEAN
336 assert entry.default_value is False
337 # advanced-only: it is a workaround, not a routine protocol choice
338 assert entry.advanced is True
339
340
341@pytest.mark.asyncio
342async def test_force_raop_toggle_hidden_for_apple_airplay2() -> None:
343 """Genuine Apple AirPlay 2 devices are always AirPlay 2, so no force-RAOP toggle is offered."""
344 player = _make_apple_player()
345 _set_discovery_info(player, raop=True, airplay=True, airplay_features=AP2_FEATURES)
346 entries = await player.get_config_entries()
347 assert all(entry.key != CONF_FORCE_RAOP for entry in entries)
348
349
350@pytest.mark.asyncio
351async def test_force_raop_toggle_hidden_for_raop_only(airplay_player: AirPlayPlayer) -> None:
352 """A RAOP-only device has nothing to force, so no toggle is offered."""
353 _set_discovery_info(airplay_player, raop=True, airplay=False)
354 entries = await airplay_player.get_config_entries()
355 assert all(entry.key != CONF_FORCE_RAOP for entry in entries)
356
357
358@pytest.mark.asyncio
359async def test_force_raop_toggle_hidden_for_airplay2_only(airplay_player: AirPlayPlayer) -> None:
360 """An AirPlay-2-only device (no RAOP service) has nothing to fall back to: no toggle."""
361 _set_discovery_info(airplay_player, raop=False, airplay=True, airplay_features=AP2_FEATURES)
362 entries = await airplay_player.get_config_entries()
363 assert all(entry.key != CONF_FORCE_RAOP for entry in entries)
364
365
366# --- Protocol resolution ---
367
368
369@pytest.mark.parametrize(
370 ("airplay_props", "raop_props", "expected"),
371 [
372 # devices advertising the AirPlay 2 feature bits get AirPlay 2
373 ({"features": AP2_FEATURES}, {}, StreamingProtocol.AIRPLAY2),
374 # the _raop ft field is used as fallback when _airplay lacks features
375 ({}, {"ft": "0x445F8A00,0x1C340"}, StreamingProtocol.AIRPLAY2),
376 # legacy receivers without the AirPlay 2 feature bits stay on RAOP
377 ({"features": "0x5A7FFFF7"}, {}, StreamingProtocol.RAOP),
378 # no features advertised at all: RAOP (safe legacy default)
379 ({}, {}, StreamingProtocol.RAOP),
380 ],
381)
382def test_protocol_resolution_follows_capability(
383 airplay_props: dict[str, str], raop_props: dict[str, str], expected: StreamingProtocol
384) -> None:
385 """Without the force toggle, protocol resolution follows the advertised AirPlay 2 bits."""
386 raop_info = MagicMock()
387 raop_info.decoded_properties = raop_props
388 airplay_info = MagicMock()
389 airplay_info.decoded_properties = airplay_props
390 player = AirPlayPlayer(
391 provider=MagicMock(),
392 player_id="test_player",
393 display_name="Test Player",
394 address="127.0.0.1",
395 manufacturer="Test Manufacturer",
396 model="Test Model",
397 raop_discovery_info=raop_info,
398 airplay_discovery_info=airplay_info,
399 )
400 _configure_player(player, {CONF_FORCE_RAOP: False})
401 assert player.protocol == expected
402
403
404def test_protocol_resolution_airplay_service_only() -> None:
405 """A device advertising only the _airplay service is AirPlay 2 even without features."""
406 airplay_info = MagicMock()
407 airplay_info.decoded_properties = {}
408 player = AirPlayPlayer(
409 provider=MagicMock(),
410 player_id="test_player",
411 display_name="Test Player",
412 address="127.0.0.1",
413 manufacturer="Test Manufacturer",
414 model="Test Model",
415 raop_discovery_info=None,
416 airplay_discovery_info=airplay_info,
417 )
418 _configure_player(player, {CONF_FORCE_RAOP: False})
419 assert player.protocol == StreamingProtocol.AIRPLAY2
420
421
422def test_force_raop_resolves_to_raop_on_non_apple_airplay2(airplay_player: AirPlayPlayer) -> None:
423 """Enabling the toggle on an eligible device forces RAOP for both resolution and stream args."""
424 _set_discovery_info(airplay_player, raop=True, airplay=True, airplay_features=AP2_FEATURES)
425 _configure_player(airplay_player, {CONF_FORCE_RAOP: True})
426 assert airplay_player.protocol == StreamingProtocol.RAOP
427 assert airplay_player.protocol_override == StreamingProtocol.RAOP
428
429
430def test_force_raop_ignored_on_apple_airplay2() -> None:
431 """A stray persisted force_raop is ignored on Apple AirPlay 2 devices (never eligible)."""
432 player = _make_apple_player()
433 _set_discovery_info(player, raop=True, airplay=True, airplay_features=AP2_FEATURES)
434 _configure_player(player, {CONF_FORCE_RAOP: True})
435 assert player.protocol == StreamingProtocol.AIRPLAY2
436 assert player.protocol_override is None
437
438
439@pytest.mark.parametrize(
440 ("stored_config", "expected"),
441 [
442 # no credentials at all: pairing is required before the player is usable
443 ({}, True),
444 # a legacy RAOP pairing keeps the player usable after the device
445 # resolves to AirPlay 2 (the binary streams RAOP-compat with the secret)
446 ({CONF_RAOP_CREDENTIALS: "clientid:secret"}, False),
447 # AirPlay 2 credentials obviously suffice as well
448 ({CONF_AIRPLAY_CREDENTIALS: "a" * 192}, False),
449 ],
450)
451def test_needs_setup_accepts_credentials_for_either_protocol(
452 airplay_player: AirPlayPlayer, stored_config: dict[str, str], expected: bool
453) -> None:
454 """A PIN-pairing device needs setup only when no credentials are stored at all."""
455 # PIN-required device that resolves to AirPlay 2 (Apple TV-like)
456 airplay_info = MagicMock()
457 airplay_info.properties = {b"flags": b"0x8"}
458 airplay_info.decoded_properties = {"features": "0x4A7FDFD5,0x3C177FDE"}
459 airplay_player.airplay_discovery_info = airplay_info
460 # credentials now live in the player's setup_data, read via get_setup_value
461 airplay_player.get_setup_value = ( # type: ignore[method-assign]
462 lambda key, default=None: stored_config.get(key, default)
463 )
464 assert airplay_player.needs_setup is expected
465
466
467# --- Hi-res playback tests ---
468
469
470def _configure_player(player: AirPlayPlayer, values: dict[str, object]) -> None:
471 """Stub the player config to return the given values."""
472 player.config.get_value.side_effect = ( # type: ignore[attr-defined]
473 lambda key, default=None: values.get(key, default)
474 )
475
476
477@pytest.mark.parametrize(
478 ("advertised_audio_formats", "force_raop", "airplay2_capable", "expected"),
479 [
480 # 24-bit advertised on the realtime stream
481 (ALAC_44100_24, False, True, [(44100, 24), (48000, 24)]),
482 # the Apple TV advertises 24-bit for its buffered stream only
483 (ALAC_48000_24, False, True, [(44100, 24), (48000, 24)]),
484 # forced RAOP cannot do 24-bit: falls back to the 16-bit base
485 (ALAC_44100_24, True, True, [(44100, 16)]),
486 # a receiver that streams RAOP never gets 24-bit, whatever it advertises
487 (ALAC_44100_24, False, False, [(44100, 16)]),
488 # only 16-bit advertised: the 16-bit default
489 (ALAC_44100_16, False, True, [(44100, 16)]),
490 # nothing advertised (unreachable device or no format tables)
491 (0, False, True, [(44100, 16)]),
492 ],
493)
494def test_hires_supported_sample_rates(
495 airplay_player: AirPlayPlayer,
496 advertised_audio_formats: int,
497 force_raop: bool,
498 airplay2_capable: bool,
499 expected: list[tuple[int, int]],
500) -> None:
501 """The formats the device advertises drive the advertised sample rates."""
502 _set_discovery_info(
503 airplay_player,
504 raop=True,
505 airplay=True,
506 airplay_features=AP2_FEATURES if airplay2_capable else None,
507 )
508 airplay_player.advertised_audio_formats = advertised_audio_formats
509 _configure_player(airplay_player, {CONF_FORCE_RAOP: force_raop})
510 assert airplay_player.supported_sample_rates == expected
511
512
513def test_get_stream_pcm_format_hires(airplay_player: AirPlayPlayer) -> None:
514 """For a 24-bit capable device the stream format is 24-bit in a s32le container."""
515 _set_discovery_info(airplay_player, raop=True, airplay=True, airplay_features=AP2_FEATURES)
516 airplay_player.advertised_audio_formats = ALAC_44100_24
517 _configure_player(airplay_player, {CONF_FORCE_RAOP: False})
518
519 session_format = AudioFormat(
520 content_type=ContentType.PCM_F32LE, sample_rate=48000, bit_depth=32
521 )
522 stream_format = airplay_player.get_stream_pcm_format(session_format)
523 # the binary expects raw s32le on stdin for --bitdepth 24
524 assert stream_format.content_type == ContentType.PCM_S32LE
525 assert stream_format.sample_rate == 48000
526 assert stream_format.bit_depth == 24
527
528 # an unsupported session rate falls back to the 44.1 kHz base
529 session_format = AudioFormat(
530 content_type=ContentType.PCM_F32LE, sample_rate=96000, bit_depth=32
531 )
532 stream_format = airplay_player.get_stream_pcm_format(session_format)
533 assert stream_format.sample_rate == 44100
534 assert stream_format.bit_depth == 24
535
536
537def test_get_stream_pcm_format_default(airplay_player: AirPlayPlayer) -> None:
538 """Without a 24-bit capable device the stream format is the 44.1/16 default."""
539 _set_discovery_info(airplay_player, raop=True, airplay=True)
540 _configure_player(airplay_player, {CONF_FORCE_RAOP: False})
541 session_format = AudioFormat(
542 content_type=ContentType.PCM_F32LE, sample_rate=48000, bit_depth=32
543 )
544 assert airplay_player.get_stream_pcm_format(session_format) == AIRPLAY_PCM_FORMAT
545
546
547@pytest.mark.asyncio
548async def test_session_pcm_format_selection(airplay_player: AirPlayPlayer) -> None:
549 """AirPlay delegates the complete session format decision to the shared selector."""
550 selected_format = AudioFormat(
551 content_type=ContentType.PCM_S24LE,
552 sample_rate=48000,
553 bit_depth=24,
554 )
555 streams_audio = cast("MagicMock", airplay_player.mass.streams.audio)
556 streams_audio.select_flow_pcm_format = AsyncMock(return_value=selected_format)
557 cast("MagicMock", airplay_player.mass.player_queues.get).return_value = None
558 media = MagicMock()
559 media.source_id = "queue1"
560 media.queue_item_id = "item1"
561 queue_item = MagicMock()
562 queue_item.streamdetails.audio_format.sample_rate = 48000
563 airplay_player.mass.player_queues.get_item.return_value = queue_item # type: ignore[attr-defined]
564 sync_clients = [airplay_player, airplay_player]
565
566 fmt = await airplay_player._get_session_pcm_format(sync_clients, media)
567
568 assert fmt is selected_format
569 streams_audio.select_flow_pcm_format.assert_awaited_once_with(
570 airplay_player,
571 start_streamdetails=queue_item.streamdetails,
572 crossfade_enabled=False,
573 overlay_active=False,
574 fallback_sample_rate=AIRPLAY_PCM_FORMAT.sample_rate,
575 output_players=sync_clients,
576 )
577
578
579@pytest.mark.asyncio
580@pytest.mark.parametrize(
581 ("normalization_mode", "expected_content_type", "expected_bit_depth"),
582 [
583 (VolumeNormalizationMode.DISABLED, ContentType.PCM_S24LE, 24),
584 (VolumeNormalizationMode.MEASUREMENT_ONLY, ContentType.PCM_F32LE, 32),
585 ],
586)
587async def test_session_pcm_format_selects_processing_depth(
588 airplay_player: AirPlayPlayer,
589 normalization_mode: VolumeNormalizationMode,
590 expected_content_type: ContentType,
591 expected_bit_depth: int,
592) -> None:
593 """An AirPlay session only uses float PCM when processing needs headroom."""
594 _set_discovery_info(airplay_player, raop=True, airplay=True, airplay_features=AP2_FEATURES)
595 airplay_player.advertised_audio_formats = ALAC_48000_24
596 _configure_player(airplay_player, {CONF_FORCE_RAOP: False})
597 airplay_player.mass.streams.audio = StreamsAudio(airplay_player.mass)
598 cast("MagicMock", airplay_player.mass.config.get_player_dsp_config).return_value = MagicMock(
599 enabled=False
600 )
601 cast(
602 "MagicMock", airplay_player.mass.streams.get_crossfade_mode
603 ).return_value = CrossfadeMode.DISABLED
604
605 streamdetails = MagicMock()
606 streamdetails.audio_format = AudioFormat(
607 content_type=ContentType.FLAC,
608 sample_rate=48000,
609 bit_depth=24,
610 )
611 streamdetails.media_type = MediaType.TRACK
612 streamdetails.volume_normalization_mode = normalization_mode
613 queue_item = MagicMock(streamdetails=streamdetails)
614 queue = MagicMock(crossfade_enabled=False, overlay_enabled=False, overlay_source=None)
615 cast("MagicMock", airplay_player.mass.player_queues.get).return_value = queue
616 cast("MagicMock", airplay_player.mass.player_queues.get_item).return_value = queue_item
617 media = MagicMock(source_id="queue1", queue_item_id="item1", media_type=MediaType.TRACK)
618
619 fmt = await airplay_player._get_session_pcm_format([airplay_player], media)
620
621 assert fmt.content_type == expected_content_type
622 assert fmt.sample_rate == 48000
623 assert fmt.bit_depth == expected_bit_depth
624
625
626# --- Volume and Mute tests ---
627
628
629def _setup_running_stream(player: AirPlayPlayer) -> AsyncMock:
630 """Attach a mock running stream to the player and return the send_cli_command mock."""
631 stream = MagicMock()
632 stream.running = True
633 # every streaming player has a session; this one is playing, not parked
634 stream.session = MagicMock(parked=False)
635 send_cmd = AsyncMock()
636 stream.send_cli_command = send_cmd
637 player.stream = stream
638 return send_cmd
639
640
641@pytest.mark.asyncio
642async def test_volume_mute_sends_zero(airplay_player: AirPlayPlayer) -> None:
643 """Muting with a running stream should send VOLUME=0."""
644 send_cmd = _setup_running_stream(airplay_player)
645 airplay_player._attr_volume_level = 75
646
647 await airplay_player.volume_mute(True)
648
649 send_cmd.assert_called_once_with("VOLUME=0")
650 assert airplay_player._attr_volume_muted is True
651
652
653@pytest.mark.asyncio
654async def test_volume_set_skipped_while_muted(airplay_player: AirPlayPlayer) -> None:
655 """Volume changes while muted should NOT send a CLI command."""
656 send_cmd = _setup_running_stream(airplay_player)
657 airplay_player._attr_volume_muted = True
658
659 await airplay_player.volume_set(60)
660
661 send_cmd.assert_not_called()
662 assert airplay_player._attr_volume_level == 60
663
664
665@pytest.mark.asyncio
666async def test_volume_set_records_level_before_sending(airplay_player: AirPlayPlayer) -> None:
667 """A resync reading the level mid-send must observe the new volume, not the old one."""
668 send_cmd = _setup_running_stream(airplay_player)
669 airplay_player._attr_volume_level = 20
670 observed: list[int | None] = []
671
672 async def read_level_while_sending(_command: str) -> bool:
673 # stands in for the connect-time volume resync, which reads the player's
674 # level while this send is still suspended
675 await asyncio.sleep(0)
676 observed.append(airplay_player.volume_level)
677 return True
678
679 send_cmd.side_effect = read_level_while_sending
680
681 await airplay_player.volume_set(80)
682
683 assert observed == [80]
684 assert airplay_player.volume_level == 80
685
686
687@pytest.mark.asyncio
688async def test_volume_set_records_level_when_the_send_fails(
689 airplay_player: AirPlayPlayer,
690) -> None:
691 """A dropped command must not lose the requested level; the resync repairs the device."""
692 send_cmd = _setup_running_stream(airplay_player)
693 send_cmd.return_value = False
694 airplay_player._attr_volume_level = 20
695
696 await airplay_player.volume_set(80)
697
698 send_cmd.assert_awaited_once_with("VOLUME=80")
699 assert airplay_player.volume_level == 80
700
701
702@pytest.mark.asyncio
703async def test_volume_unmute_restores_volume(airplay_player: AirPlayPlayer) -> None:
704 """Unmuting with a running stream should send VOLUME={current_volume}."""
705 send_cmd = _setup_running_stream(airplay_player)
706 airplay_player._attr_volume_level = 42
707 airplay_player._attr_volume_muted = True
708
709 await airplay_player.volume_mute(False)
710
711 send_cmd.assert_called_once_with("VOLUME=42")
712 assert airplay_player._attr_volume_muted is False
713
714
715@pytest.mark.asyncio
716async def test_volume_mute_no_stream(airplay_player: AirPlayPlayer) -> None:
717 """Muting without a running stream should update state without CLI commands."""
718 airplay_player.stream = None
719
720 with patch.object(AirPlayPlayer, "update_state") as mock_update:
721 await airplay_player.volume_mute(True)
722
723 assert airplay_player._attr_volume_muted is True
724 mock_update.assert_called_once()
725
726
727def test_sync_volume_state_keeps_stored_volume_for_native_parent(
728 airplay_player: AirPlayPlayer,
729) -> None:
730 """Keep the child AirPlay volume when the parent uses native volume control."""
731 parent = MagicMock()
732 parent.state.volume_level = 36
733 parent.volume_control_for_output.return_value = PLAYER_CONTROL_NATIVE
734 parent.mute_control_for_output.return_value = PLAYER_CONTROL_NATIVE
735 airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined]
736 airplay_player.set_protocol_parent_id("parent")
737 airplay_player._attr_volume_level = 48
738
739 with patch.object(AirPlayPlayer, "update_state") as mock_update:
740 airplay_player.sync_volume_state()
741
742 assert airplay_player._attr_volume_level == 48
743 airplay_player.mass.config.set_raw_player_config_value.assert_not_called() # type: ignore[attr-defined]
744 mock_update.assert_not_called()
745
746
747def test_update_volume_from_device_keeps_native_parent_feedback(
748 airplay_player: AirPlayPlayer,
749) -> None:
750 """Use DACP feedback to keep the child AirPlay volume current."""
751 parent = MagicMock()
752 parent.state.volume_level = 42
753 parent.volume_control = PLAYER_CONTROL_NATIVE
754 airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined]
755 airplay_player.config.get_value.return_value = False # type: ignore[attr-defined]
756 airplay_player.set_protocol_parent_id("parent")
757 airplay_player._attr_volume_level = 57
758 airplay_player.last_command_sent = time.time()
759
760 with patch.object(AirPlayPlayer, "update_state") as mock_update:
761 airplay_player.update_volume_from_device(57)
762
763 assert airplay_player._attr_volume_level == 57
764 airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( # type: ignore[attr-defined]
765 airplay_player.player_id, CONF_STORED_VOLUME, 57
766 )
767 mock_update.assert_called_once()
768
769
770def test_sync_volume_state_uses_parent_volume_when_control_is_self(
771 airplay_player: AirPlayPlayer,
772) -> None:
773 """Pull the parent volume when the parent's volume control is this player."""
774 parent = MagicMock()
775 parent.state.volume_level = 42
776 parent.volume_control_for_output.return_value = "test_player"
777 parent.mute_control_for_output.return_value = PLAYER_CONTROL_NATIVE
778 airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined]
779 airplay_player.set_protocol_parent_id("parent")
780 airplay_player._attr_volume_level = 48
781
782 with patch.object(AirPlayPlayer, "update_state") as mock_update:
783 airplay_player.sync_volume_state()
784
785 assert airplay_player._attr_volume_level == 42
786 airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( # type: ignore[attr-defined]
787 airplay_player.player_id,
788 CONF_STORED_VOLUME,
789 42,
790 )
791 mock_update.assert_called_once()
792
793
794def test_sync_volume_state_uses_parent_volume_via_bridge_control(
795 airplay_player: AirPlayPlayer,
796) -> None:
797 """Pull the parent volume when the volume control is a bridge riding on this player."""
798 parent = MagicMock()
799 parent.state.volume_level = 42
800 parent.volume_control_for_output.return_value = "sendspin_bridge"
801 parent.mute_control_for_output.return_value = PLAYER_CONTROL_NATIVE
802 bridge = MagicMock()
803 bridge.underlying_player_id = "test_player"
804 airplay_player.mass.players.get_player.side_effect = { # type: ignore[attr-defined]
805 "parent": parent,
806 "sendspin_bridge": bridge,
807 }.get
808 airplay_player.set_protocol_parent_id("parent")
809 airplay_player._attr_volume_level = 48
810
811 with patch.object(AirPlayPlayer, "update_state") as mock_update:
812 airplay_player.sync_volume_state()
813
814 assert airplay_player._attr_volume_level == 42
815 airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( # type: ignore[attr-defined]
816 airplay_player.player_id,
817 CONF_STORED_VOLUME,
818 42,
819 )
820 mock_update.assert_called_once()
821
822
823def test_sync_volume_state_unity_gain_when_other_control_owns_volume(
824 airplay_player: AirPlayPlayer,
825) -> None:
826 """Play at unity gain when the parent volume is owned by another (hardware) control."""
827 parent = MagicMock()
828 parent.state.volume_level = 30
829 parent.volume_control_for_output.return_value = "dlna_player"
830 parent.mute_control_for_output.return_value = PLAYER_CONTROL_NATIVE
831 dlna_player = MagicMock()
832 dlna_player.underlying_player_id = None
833 airplay_player.mass.players.get_player.side_effect = { # type: ignore[attr-defined]
834 "parent": parent,
835 "dlna_player": dlna_player,
836 }.get
837 airplay_player.set_protocol_parent_id("parent")
838 airplay_player._attr_volume_level = 48
839
840 with patch.object(AirPlayPlayer, "update_state") as mock_update:
841 airplay_player.sync_volume_state()
842
843 assert airplay_player._attr_volume_level == 100
844 airplay_player.mass.config.set_raw_player_config_value.assert_not_called() # type: ignore[attr-defined]
845 mock_update.assert_called_once()
846
847
848def test_sync_volume_state_ignores_parent_volume_zero(
849 airplay_player: AirPlayPlayer,
850) -> None:
851 """
852 Keep the last known volume when the parent reports volume 0.
853
854 An idle sibling interface (e.g. the cast side of the same device in standby)
855 may feed the parent a volume of 0 that doesn't reflect the real device volume;
856 adopting it would start the stream hard muted.
857 """
858 parent = MagicMock()
859 parent.state.volume_level = 0
860 parent.volume_control_for_output.return_value = "test_player"
861 parent.mute_control_for_output.return_value = PLAYER_CONTROL_NATIVE
862 airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined]
863 airplay_player.set_protocol_parent_id("parent")
864 airplay_player._attr_volume_level = 48
865
866 with patch.object(AirPlayPlayer, "update_state") as mock_update:
867 airplay_player.sync_volume_state()
868
869 assert airplay_player._attr_volume_level == 48
870 airplay_player.mass.config.set_raw_player_config_value.assert_not_called() # type: ignore[attr-defined]
871 mock_update.assert_not_called()
872
873
874def test_sync_volume_state_clears_mute_latch_owned_by_other_control(
875 airplay_player: AirPlayPlayer,
876) -> None:
877 """
878 Clear our mute when it is owned by a control that doesn't render this stream.
879
880 The mute is a latch that only an explicit unmute clears, so a mute applied while
881 a sibling interface owned the parent would otherwise start this stream silent and
882 swallow every volume command after it.
883 """
884 parent = MagicMock()
885 parent.state.volume_level = 40
886 parent.state.volume_muted = False
887 parent.volume_control_for_output.return_value = PLAYER_CONTROL_NATIVE
888 parent.mute_control_for_output.return_value = "cast_player"
889 cast_player = MagicMock()
890 cast_player.underlying_player_id = None
891 airplay_player.mass.players.get_player.side_effect = { # type: ignore[attr-defined]
892 "parent": parent,
893 "cast_player": cast_player,
894 }.get
895 airplay_player.set_protocol_parent_id("parent")
896 airplay_player._attr_volume_muted = True
897
898 with patch.object(AirPlayPlayer, "update_state") as mock_update:
899 airplay_player.sync_volume_state()
900
901 assert airplay_player._attr_volume_muted is False
902 mock_update.assert_called_once()
903
904
905def test_sync_volume_state_keeps_mute_owned_by_this_output(
906 airplay_player: AirPlayPlayer,
907) -> None:
908 """Keep our own mute when this player owns the parent's mute."""
909 parent = MagicMock()
910 parent.state.volume_level = 40
911 parent.state.volume_muted = True
912 parent.volume_control_for_output.return_value = PLAYER_CONTROL_NATIVE
913 parent.mute_control_for_output.return_value = "test_player"
914 airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined]
915 airplay_player.set_protocol_parent_id("parent")
916 airplay_player._attr_volume_muted = True
917
918 with patch.object(AirPlayPlayer, "update_state") as mock_update:
919 airplay_player.sync_volume_state()
920
921 assert airplay_player._attr_volume_muted is True
922 mock_update.assert_not_called()
923
924
925def test_sync_volume_state_leaves_unknown_mute_untouched(
926 airplay_player: AirPlayPlayer,
927) -> None:
928 """Never having latched a mute is not the same as being unmuted."""
929 parent = MagicMock()
930 parent.state.volume_level = 48
931 parent.state.volume_muted = False
932 parent.volume_control_for_output.return_value = PLAYER_CONTROL_NATIVE
933 parent.mute_control_for_output.return_value = "cast_player"
934 cast_player = MagicMock()
935 cast_player.underlying_player_id = None
936 airplay_player.mass.players.get_player.side_effect = { # type: ignore[attr-defined]
937 "parent": parent,
938 "cast_player": cast_player,
939 }.get
940 airplay_player.set_protocol_parent_id("parent")
941 airplay_player._attr_volume_muted = None
942
943 with patch.object(AirPlayPlayer, "update_state") as mock_update:
944 airplay_player.sync_volume_state()
945
946 assert airplay_player._attr_volume_muted is None
947 mock_update.assert_not_called()
948
949
950def test_sync_volume_state_resolves_against_this_output(
951 airplay_player: AirPlayPlayer,
952) -> None:
953 """Both controls are resolved for this player as the rendering output."""
954 parent = MagicMock()
955 parent.state.volume_level = 40
956 parent.state.volume_muted = False
957 parent.volume_control_for_output.return_value = PLAYER_CONTROL_NATIVE
958 parent.mute_control_for_output.return_value = PLAYER_CONTROL_NATIVE
959 airplay_player.mass.players.get_player.return_value = parent # type: ignore[attr-defined]
960 airplay_player.set_protocol_parent_id("parent")
961 airplay_player._attr_volume_muted = True
962
963 airplay_player.sync_volume_state()
964
965 parent.volume_control_for_output.assert_called_once_with("test_player")
966 parent.mute_control_for_output.assert_called_once_with("test_player")
967
968
969# --- Pause / stop dispatch tests ---
970
971
972def test_supported_features_always_includes_pause(airplay_player: AirPlayPlayer) -> None:
973 """
974 PAUSE stays advertised whether or not the player is grouped.
975
976 Keeping PAUSE keeps the AirPlay player itself as the pause control target, so a
977 grouped pause parks the complete session (see pause()) instead of the players
978 controller falling through to a linked native player's pause, which would only
979 pause the sync leader while the other members keep playing.
980 """
981 airplay_player._attr_group_members = []
982 assert PlayerFeature.PAUSE in airplay_player.supported_features
983 # sync leader: still advertises PAUSE
984 airplay_player._attr_group_members = ["test_player", "child"]
985 assert PlayerFeature.PAUSE in airplay_player.supported_features
986
987
988def test_bridged_player_advertises_announcements_only_while_streaming(
989 airplay_player: AirPlayPlayer,
990) -> None:
991 """
992 A Sendspin-bridged player advertises PLAY_ANNOUNCEMENT only while streaming.
993
994 The bridge's live stream is a regular AirPlayStream the clip mixes into, so
995 the feature stays available then. An idle bridged player must not advertise
996 it - a dedicated announcement session would fight the bridge for the
997 device, so those announcements keep their existing routing.
998 """
999 bridge_manager = cast("AirPlayProvider", airplay_player.provider).bridge_manager
1000 with patch.object(bridge_manager, "get_bridge", return_value=None):
1001 assert PlayerFeature.PLAY_ANNOUNCEMENT in airplay_player.supported_features
1002 streaming_bridge = MagicMock(owns_airplay_stream=True)
1003 with patch.object(bridge_manager, "get_bridge", return_value=streaming_bridge):
1004 assert PlayerFeature.PLAY_ANNOUNCEMENT in airplay_player.supported_features
1005 idle_bridge = MagicMock(owns_airplay_stream=False)
1006 with patch.object(bridge_manager, "get_bridge", return_value=idle_bridge):
1007 assert PlayerFeature.PLAY_ANNOUNCEMENT not in airplay_player.supported_features
1008 # ... but a configured-yet-idle bridge never hides the feature while the
1009 # player runs its own session-backed stream (playing over AirPlay itself)
1010 airplay_player.stream = MagicMock(running=True, session=MagicMock())
1011 assert PlayerFeature.PLAY_ANNOUNCEMENT in airplay_player.supported_features
1012 airplay_player.stream = None
1013
1014
1015@pytest.mark.asyncio
1016async def test_single_player_play_sends_action_play(airplay_player: AirPlayPlayer) -> None:
1017 """An unsynced player resumes its paused stream in place with ACTION=PLAY."""
1018 airplay_player._attr_group_members = []
1019 send_cmd = _setup_running_stream(airplay_player)
1020 send_cmd.return_value = True
1021
1022 await airplay_player.play()
1023
1024 send_cmd.assert_awaited_once_with("ACTION=PLAY")
1025 # resume re-anchors the binary, so the tracked re-anchor shift is reset to match
1026 cast("MagicMock", airplay_player.stream).reset_reanchor_shift.assert_called_once_with()
1027
1028
1029@pytest.mark.asyncio
1030async def test_single_player_play_keeps_shift_when_resume_not_delivered(
1031 airplay_player: AirPlayPlayer,
1032) -> None:
1033 """An undelivered ACTION=PLAY leaves the tracked re-anchor shift untouched."""
1034 airplay_player._attr_group_members = []
1035 send_cmd = _setup_running_stream(airplay_player)
1036 send_cmd.return_value = False
1037
1038 await airplay_player.play()
1039
1040 send_cmd.assert_awaited_once_with("ACTION=PLAY")
1041 cast("MagicMock", airplay_player.stream).reset_reanchor_shift.assert_not_called()
1042
1043
1044@pytest.mark.asyncio
1045async def test_grouped_play_resumes_active_native_queue(airplay_player: AirPlayPlayer) -> None:
1046 """A linked AirPlay group resumes the queue owned by its native parent."""
1047 airplay_player._attr_group_members = ["test_player", "child"]
1048 send_cmd = _setup_running_stream(airplay_player)
1049 active_queue = MagicMock(queue_id="native_parent")
1050
1051 with (
1052 patch.object(
1053 airplay_player.mass.players, "get_active_queue", return_value=active_queue
1054 ) as get_active_queue,
1055 patch.object(
1056 airplay_player.mass.player_queues, "resume", new_callable=AsyncMock
1057 ) as resume_queue,
1058 ):
1059 await airplay_player.play()
1060
1061 get_active_queue.assert_called_once_with(airplay_player)
1062 resume_queue.assert_awaited_once_with("native_parent", fade_in=False)
1063 send_cmd.assert_not_awaited()
1064
1065
1066@pytest.mark.asyncio
1067async def test_single_player_pause_sends_action_pause(airplay_player: AirPlayPlayer) -> None:
1068 """An unsynced player pauses the stream in place with ACTION=PAUSE."""
1069 airplay_player._attr_group_members = []
1070 airplay_player.mass.players.iter_players.return_value = [] # type: ignore[attr-defined]
1071 send_cmd = _setup_running_stream(airplay_player)
1072
1073 with patch.object(AirPlayPlayer, "stop", new=AsyncMock()) as mock_stop:
1074 await airplay_player.pause()
1075
1076 send_cmd.assert_called_once_with("ACTION=PAUSE")
1077 mock_stop.assert_not_called()
1078
1079
1080@pytest.mark.asyncio
1081async def test_grouped_leader_pause_parks_session(airplay_player: AirPlayPlayer) -> None:
1082 """A sync leader pauses by parking the session (standby), never sending ACTION=PAUSE."""
1083 airplay_player._attr_group_members = ["test_player", "child"]
1084 send_cmd = _setup_running_stream(airplay_player)
1085 assert airplay_player.stream is not None
1086 session = cast("MagicMock", airplay_player.stream.session)
1087 session.standby = AsyncMock(return_value=True)
1088
1089 with patch.object(AirPlayPlayer, "stop", new=AsyncMock()) as mock_stop:
1090 await airplay_player.pause()
1091
1092 session.standby.assert_awaited_once()
1093 mock_stop.assert_not_called()
1094 send_cmd.assert_not_called()
1095
1096
1097@pytest.mark.asyncio
1098async def test_grouped_pause_falls_back_to_stop(airplay_player: AirPlayPlayer) -> None:
1099 """When a member cannot be parked, grouped pause stops the session."""
1100 airplay_player._attr_group_members = ["test_player", "child"]
1101 send_cmd = _setup_running_stream(airplay_player)
1102 assert airplay_player.stream is not None
1103 session = cast("MagicMock", airplay_player.stream.session)
1104 session.standby = AsyncMock(return_value=False)
1105
1106 with patch.object(AirPlayPlayer, "stop", new=AsyncMock()) as mock_stop:
1107 await airplay_player.pause()
1108
1109 mock_stop.assert_called_once()
1110 send_cmd.assert_not_called()
1111
1112
1113@pytest.mark.asyncio
1114async def test_synced_child_pause_parks_session(airplay_player: AirPlayPlayer) -> None:
1115 """A synced child also pauses by parking the shared session, never ACTION=PAUSE."""
1116 airplay_player._attr_group_members = []
1117 send_cmd = _setup_running_stream(airplay_player)
1118 assert airplay_player.stream is not None
1119 session = cast("MagicMock", airplay_player.stream.session)
1120 session.standby = AsyncMock(return_value=True)
1121
1122 with (
1123 patch.object(AirPlayPlayer, "synced_to", new_callable=PropertyMock, return_value="parent"),
1124 patch.object(AirPlayPlayer, "stop", new=AsyncMock()) as mock_stop,
1125 ):
1126 await airplay_player.pause()
1127
1128 session.standby.assert_awaited_once()
1129 mock_stop.assert_not_called()
1130 send_cmd.assert_not_called()
1131
1132
1133# --- Automatic group re-join after unexpected stream loss ---
1134
1135
1136def _make_idle_player(player_id: str = "test_player") -> AirPlayPlayer:
1137 """Create an idle, ungrouped AirPlayPlayer wired for the re-join tests."""
1138 player = AirPlayPlayer(
1139 provider=MagicMock(),
1140 player_id=player_id,
1141 display_name=f"Player {player_id}",
1142 address="127.0.0.1",
1143 manufacturer="Test Manufacturer",
1144 model="Test Model",
1145 raop_discovery_info=None,
1146 airplay_discovery_info=None,
1147 )
1148 # the synced_to property scans all players of the provider
1149 _players_mock(player).iter_players.return_value = []
1150 player._attr_group_members = []
1151 player._attr_playback_state = PlaybackState.IDLE
1152 player.stream = None
1153 return player
1154
1155
1156def _make_playing_leader(player_id: str = "leader") -> AirPlayPlayer:
1157 """Create an AirPlayPlayer that looks like the playing leader of a live session."""
1158 leader = _make_idle_player(player_id)
1159 leader._attr_playback_state = PlaybackState.PLAYING
1160 stream = MagicMock()
1161 stream.running = True
1162 stream.session = MagicMock(parked=False)
1163 leader.stream = stream
1164 return leader
1165
1166
1167def _players_mock(player: AirPlayPlayer) -> MagicMock:
1168 """Return the mocked players controller of the given player."""
1169 return cast("MagicMock", player.mass.players)
1170
1171
1172def _attach_running_session(player: AirPlayPlayer, sync_clients: list[AirPlayPlayer]) -> None:
1173 """Attach a mock running stream whose session carries the given members."""
1174 stream = MagicMock()
1175 stream.running = True
1176 stream.session = MagicMock(parked=False)
1177 stream.session.sync_clients = sync_clients
1178 player.stream = stream
1179
1180
1181_NO_DELAYS = "music_assistant.providers.airplay.player.AIRPLAY_REJOIN_ATTEMPT_DELAYS"
1182
1183
1184@pytest.mark.asyncio
1185async def test_rejoin_succeeds_on_first_attempt() -> None:
1186 """A re-join attempt joins the player back to the playing leader's session."""
1187 player = _make_idle_player()
1188 leader = _make_playing_leader()
1189 players_mock = _players_mock(player)
1190 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1191
1192 async def cmd_group(player_id: str, target_id: str) -> None:
1193 assert player_id == player.player_id
1194 assert target_id == leader.player_id
1195 _attach_running_session(player, [leader, player])
1196
1197 players_mock.cmd_group = AsyncMock(side_effect=cmd_group)
1198 players_mock.cmd_ungroup = AsyncMock()
1199
1200 with patch(_NO_DELAYS, (0, 0, 0)):
1201 await player._group_rejoin_attempts(["leader"])
1202
1203 assert players_mock.cmd_group.await_count == 1
1204 players_mock.cmd_ungroup.assert_not_awaited()
1205
1206
1207@pytest.mark.asyncio
1208async def test_rejoin_retries_after_failure_then_succeeds() -> None:
1209 """A failed attempt (device still unreachable) is retried with backoff."""
1210 player = _make_idle_player()
1211 leader = _make_playing_leader()
1212 players_mock = _players_mock(player)
1213 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1214 attempts: list[int] = []
1215
1216 async def cmd_group(_player_id: str, _target_id: str) -> None:
1217 attempts.append(1)
1218 if len(attempts) == 1:
1219 raise PlayerCommandFailed("device unreachable")
1220 _attach_running_session(player, [leader, player])
1221
1222 players_mock.cmd_group = AsyncMock(side_effect=cmd_group)
1223
1224 with patch(_NO_DELAYS, (0, 0, 0)):
1225 await player._group_rejoin_attempts(["leader"])
1226
1227 assert len(attempts) == 2
1228
1229
1230@pytest.mark.asyncio
1231async def test_rejoin_gives_up_after_all_attempts() -> None:
1232 """Without a playing group to re-join, the attempts run out and stop cleanly."""
1233 player = _make_idle_player()
1234 players_mock = _players_mock(player)
1235 players_mock.get_player.return_value = None
1236 players_mock.cmd_group = AsyncMock()
1237
1238 with patch(_NO_DELAYS, (0, 0, 0)):
1239 await player._group_rejoin_attempts(["leader"])
1240
1241 players_mock.cmd_group.assert_not_awaited()
1242
1243
1244@pytest.mark.asyncio
1245async def test_rejoin_aborts_when_player_used_meanwhile() -> None:
1246 """A player that was grouped or repurposed meanwhile is left alone."""
1247 player = _make_idle_player()
1248 leader = _make_playing_leader()
1249 players_mock = _players_mock(player)
1250 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1251 players_mock.cmd_group = AsyncMock()
1252 # the user started something else on the player during the backoff
1253 stream = MagicMock()
1254 stream.running = True
1255 player.stream = stream
1256
1257 with patch(_NO_DELAYS, (0, 0, 0)):
1258 await player._group_rejoin_attempts(["leader"])
1259
1260 players_mock.cmd_group.assert_not_awaited()
1261
1262
1263@pytest.mark.asyncio
1264async def test_rejoin_undoes_dangling_membership() -> None:
1265 """A join that yields no running stream is rolled back before the next attempt."""
1266 player = _make_idle_player()
1267 leader = _make_playing_leader()
1268 players_mock = _players_mock(player)
1269 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1270 # cmd_group "succeeds" but the late-join failed internally: no stream appears
1271 players_mock.cmd_group = AsyncMock()
1272 players_mock.cmd_ungroup = AsyncMock()
1273
1274 with patch(_NO_DELAYS, (0, 0, 0)):
1275 await player._group_rejoin_attempts(["leader"])
1276
1277 # every attempt rolled its dangling membership back
1278 assert players_mock.cmd_group.await_count == 3
1279 assert players_mock.cmd_ungroup.await_count == 3
1280
1281
1282@pytest.mark.asyncio
1283async def test_rejoin_cancelled_when_player_unavailable() -> None:
1284 """An offline player abandons the re-join right away instead of attempting."""
1285 player = _make_idle_player()
1286 leader = _make_playing_leader()
1287 players_mock = _players_mock(player)
1288 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1289 players_mock.cmd_group = AsyncMock()
1290 player._attr_available = False
1291
1292 # the later long delays prove the loop returns on the first pass
1293 with patch(_NO_DELAYS, (0, 60, 60)):
1294 await asyncio.wait_for(player._group_rejoin_attempts(["leader"]), timeout=5)
1295
1296 players_mock.cmd_group.assert_not_awaited()
1297 players_mock.get_player.assert_not_called()
1298
1299
1300@pytest.mark.asyncio
1301async def test_rejoin_aborts_when_synced_into_foreign_group() -> None:
1302 """A player the user grouped elsewhere meanwhile is left alone."""
1303 player = _make_idle_player()
1304 leader = _make_playing_leader()
1305 foreign_leader = MagicMock()
1306 foreign_leader.player_id = "other"
1307 foreign_leader.group_members = ["other", player.player_id]
1308 # the player reports it is now synced to a leader outside the original group
1309 _players_mock(player).iter_players.return_value = [foreign_leader]
1310 players_mock = _players_mock(player)
1311 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1312 players_mock.cmd_group = AsyncMock()
1313
1314 with patch(_NO_DELAYS, (0, 60, 60)):
1315 await asyncio.wait_for(player._group_rejoin_attempts(["leader"]), timeout=5)
1316
1317 players_mock.cmd_group.assert_not_awaited()
1318
1319
1320def test_resolve_rejoin_target_finds_promoted_sibling() -> None:
1321 """When the old leader is gone, a promoted (now leading) sibling is the target."""
1322 player = _make_idle_player()
1323 sibling = _make_playing_leader("sibling")
1324 sibling._attr_group_members = ["sibling", "other_member"]
1325 _players_mock(player).get_player.side_effect = lambda player_id: {"sibling": sibling}.get(
1326 player_id
1327 )
1328
1329 assert player._resolve_rejoin_target(["old_leader", "sibling"]) is sibling
1330
1331
1332def test_resolve_rejoin_target_skips_candidate_in_foreign_group() -> None:
1333 """A candidate absorbed into another group is never followed there."""
1334 player = _make_idle_player()
1335 foreign_leader = _make_playing_leader("foreign")
1336 foreign_leader._attr_group_members = ["foreign", "old_leader"]
1337 old_leader = _make_playing_leader("old_leader")
1338 # the old leader reports it is now synced to the foreign leader
1339 _players_mock(old_leader).iter_players.return_value = [foreign_leader]
1340 _players_mock(player).get_player.side_effect = lambda player_id: {
1341 "old_leader": old_leader,
1342 "foreign": foreign_leader,
1343 }.get(player_id)
1344
1345 assert player._resolve_rejoin_target(["old_leader"]) is None
1346
1347
1348@pytest.mark.parametrize(
1349 ("playback_state", "stream_running", "available", "expected"),
1350 [
1351 (PlaybackState.PLAYING, True, True, True),
1352 # a parked (paused) session has no live timeline to late-join
1353 (PlaybackState.PAUSED, True, True, False),
1354 (PlaybackState.IDLE, False, True, False),
1355 # target device itself dropped off the network
1356 (PlaybackState.PLAYING, True, False, False),
1357 ],
1358)
1359def test_resolve_rejoin_target_requires_playing_session(
1360 playback_state: PlaybackState, stream_running: bool, available: bool, expected: bool
1361) -> None:
1362 """Only an available target with an actively playing session is accepted."""
1363 player = _make_idle_player()
1364 leader = _make_playing_leader()
1365 leader._attr_playback_state = playback_state
1366 cast("MagicMock", leader.stream).running = stream_running
1367 leader._attr_available = available
1368 _players_mock(player).get_player.side_effect = lambda player_id: {"leader": leader}.get(
1369 player_id
1370 )
1371
1372 target = player._resolve_rejoin_target(["leader"])
1373 assert (target is leader) is expected
1374
1375
1376@pytest.mark.asyncio
1377async def test_rejoin_heals_session_when_membership_survived() -> None:
1378 """A player still holding sync membership (static group) heals the session only."""
1379 player = _make_idle_player()
1380 leader = _make_playing_leader()
1381 # the sync membership survived the stream loss: the player is still listed
1382 # as a member of (and synced to) the leader
1383 leader._attr_group_members = ["leader", player.player_id]
1384 _players_mock(player).iter_players.return_value = [leader]
1385 players_mock = _players_mock(player)
1386 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1387 players_mock.cmd_group = AsyncMock()
1388 players_mock.cmd_ungroup = AsyncMock()
1389 session = cast("MagicMock", leader.stream).session
1390
1391 async def add_client(joiner: AirPlayPlayer) -> None:
1392 assert joiner is player
1393 _attach_running_session(player, [leader, player])
1394
1395 session.add_client = AsyncMock(side_effect=add_client)
1396
1397 with patch(_NO_DELAYS, (0,)):
1398 await player._group_rejoin_attempts(["leader"])
1399
1400 session.add_client.assert_awaited_once()
1401 players_mock.cmd_group.assert_not_awaited()
1402 players_mock.cmd_ungroup.assert_not_awaited()
1403
1404
1405@pytest.mark.asyncio
1406async def test_rejoin_session_heal_failure_keeps_membership() -> None:
1407 """A failed session heal never touches the (configured) group membership."""
1408 player = _make_idle_player()
1409 leader = _make_playing_leader()
1410 leader._attr_group_members = ["leader", player.player_id]
1411 _players_mock(player).iter_players.return_value = [leader]
1412 players_mock = _players_mock(player)
1413 players_mock.get_player.side_effect = lambda player_id: {"leader": leader}.get(player_id)
1414 players_mock.cmd_group = AsyncMock()
1415 players_mock.cmd_ungroup = AsyncMock()
1416 session = cast("MagicMock", leader.stream).session
1417 # the late-join fails internally: no stream appears on the player
1418 session.add_client = AsyncMock()
1419
1420 with patch(_NO_DELAYS, (0,)):
1421 await player._group_rejoin_attempts(["leader"])
1422
1423 session.add_client.assert_awaited_once()
1424 players_mock.cmd_ungroup.assert_not_awaited()
1425
1426
1427@pytest.mark.asyncio
1428async def test_schedule_and_cancel_group_rejoin() -> None:
1429 """Scheduling replaces a pending task and cancelling stops it."""
1430 player = _make_idle_player()
1431 _players_mock(player).get_player.return_value = None
1432 cast("MagicMock", player.mass).create_task = lambda coro: (
1433 asyncio.get_running_loop().create_task(coro)
1434 )
1435
1436 with patch(_NO_DELAYS, (60, 60, 60)):
1437 player.schedule_group_rejoin(["leader"])
1438 first_task = player._rejoin_task
1439 assert first_task is not None
1440 # a second death replaces the pending schedule
1441 player.schedule_group_rejoin(["leader"])
1442 second_task = player._rejoin_task
1443 assert second_task is not None
1444 assert second_task is not first_task
1445 await asyncio.sleep(0)
1446 assert first_task.cancelled()
1447 # any deliberate use of the player cancels the pending re-join
1448 player.cancel_group_rejoin()
1449 assert player._rejoin_task is None
1450 await asyncio.sleep(0)
1451 assert second_task.cancelled()
1452
1453
1454@pytest.mark.asyncio
1455async def test_stop_cancels_pending_rejoin() -> None:
1456 """An explicit stop command on the player drops the pending re-join."""
1457 player = _make_idle_player()
1458 _players_mock(player).get_player.return_value = None
1459 cast("MagicMock", player.mass).create_task = lambda coro: (
1460 asyncio.get_running_loop().create_task(coro)
1461 )
1462
1463 with (
1464 patch(_NO_DELAYS, (60,)),
1465 patch.object(AirPlayPlayer, "update_state"),
1466 ):
1467 player.schedule_group_rejoin(["leader"])
1468 rejoin_task = player._rejoin_task
1469 assert rejoin_task is not None
1470 await player.stop()
1471 assert player._rejoin_task is None
1472 await asyncio.sleep(0)
1473 assert rejoin_task.cancelled()
1474
1475
1476# --- Group membership and the leader's stream session ---
1477
1478
1479@pytest.mark.asyncio
1480async def test_set_members_adds_the_child_to_the_running_session() -> None:
1481 """A member joining a leader with a live session is added to that session."""
1482 leader = _make_playing_leader()
1483 child = _make_idle_player("child")
1484 _attach_running_session(leader, [leader])
1485 session = cast("MagicMock", leader.stream).session
1486 session.add_client = AsyncMock()
1487 _players_mock(leader).get_player.side_effect = lambda player_id: {"child": child}.get(player_id)
1488
1489 await leader.set_members(player_ids_to_add=["child"])
1490
1491 session.add_client.assert_awaited_once_with(child)
1492 assert leader.group_members == ["leader", "child"]
1493
1494
1495def test_live_session_members_reports_who_the_session_actually_feeds() -> None:
1496 """Group membership outlives the session, so only the session itself can answer."""
1497 leader = _make_playing_leader()
1498 leader._attr_group_members = ["leader", "child"]
1499 # the session dropped the child (e.g. its receiver never answered our clock)
1500 _attach_running_session(leader, [leader])
1501
1502 assert leader.live_session_members == ["leader"]
1503
1504 # no session means nobody is being rendered with, whatever the group says
1505 stream = cast("MagicMock", leader.stream)
1506 stream.running = False
1507 assert leader.live_session_members == []
1508 stream.running = True
1509 stream.session = None
1510 assert leader.live_session_members == []
1511 leader.stream = None
1512 assert leader.live_session_members == []
1513
1514
1515@pytest.mark.asyncio
1516async def test_set_members_warns_when_the_leader_has_no_session(
1517 caplog: pytest.LogCaptureFixture,
1518) -> None:
1519 """A member joining a leader that renders through a protocol gets no audio, loudly."""
1520 leader = _make_idle_player("leader")
1521 leader.logger = logging.getLogger("test.airplay.player")
1522 child = _make_idle_player("child")
1523 _players_mock(leader).get_player.side_effect = lambda player_id: {"child": child}.get(player_id)
1524 # the leader hands its audio to one of its output protocols, so it has no session
1525 leader.set_active_output_protocol("bridge_leader")
1526
1527 with caplog.at_level(logging.WARNING):
1528 await leader.set_members(player_ids_to_add=["child"])
1529
1530 assert leader.group_members == ["leader", "child"]
1531 assert "no stream session to join" in caplog.text
1532
1533
1534def _attach_live_stream(player: AirPlayPlayer, session: AirPlayStreamSession) -> MagicMock:
1535 """Attach a mock stream that is connected and fed by the given session."""
1536 stream = MagicMock()
1537 stream.running = True
1538 stream.connected = True
1539 stream.session = session
1540 stream.send_cli_command = AsyncMock(return_value=True)
1541 stream.stop = AsyncMock()
1542 player.stream = stream
1543 return stream
1544
1545
1546@pytest.mark.asyncio
1547async def test_play_after_ungrouping_a_parked_group_resumes_via_the_queue() -> None:
1548 """
1549 Breaking a parked group up leaves the remaining player alone with the park.
1550
1551 Its binary is held at standby with nothing being fed, so the resume still has
1552 to re-anchor through the queue: ACTION=PLAY carries no anchor and would
1553 report playback over silence.
1554 """
1555 leader = _make_idle_player("leader")
1556 child = _make_idle_player("child")
1557 session = AirPlayStreamSession(
1558 MagicMock(mass=leader.mass), [leader, child], AIRPLAY_PCM_FORMAT, MagicMock()
1559 )
1560 leader_stream = _attach_live_stream(leader, session)
1561 child_stream = _attach_live_stream(child, session)
1562 leader._attr_group_members = ["leader", "child"]
1563 players = _players_mock(leader)
1564 players.get_player.side_effect = lambda player_id: {"leader": leader, "child": child}.get(
1565 player_id
1566 )
1567 players.get_active_queue.return_value = MagicMock(queue_id="leader")
1568 resume_queue = AsyncMock()
1569 cast("MagicMock", leader.mass).player_queues.resume = resume_queue
1570
1571 await leader.pause()
1572 await leader.set_members(player_ids_to_remove=["child"])
1573 leader_stream.send_cli_command.reset_mock()
1574 await leader.play()
1575
1576 # the removal stops only the child; the leader keeps its parked session
1577 child_stream.stop.assert_awaited_once()
1578 leader_stream.stop.assert_not_awaited()
1579 assert leader.group_members == []
1580 assert session.sync_clients == [leader]
1581 assert session.parked is True
1582 resume_queue.assert_awaited_once_with("leader", fade_in=False)
1583 leader_stream.send_cli_command.assert_not_awaited()
1584
1585
1586@pytest.mark.asyncio
1587async def test_leader_stepping_out_alone_keeps_the_session_for_the_others() -> None:
1588 """
1589 A leader that only removes itself hands the live session to the members left behind.
1590
1591 The leader is not asked to take the others with it, so tearing the session
1592 down here would cut off members that are still supposed to be playing.
1593 """
1594 leader = _make_idle_player("leader")
1595 child = _make_idle_player("child")
1596 session = AirPlayStreamSession(
1597 MagicMock(mass=leader.mass), [leader, child], AIRPLAY_PCM_FORMAT, MagicMock()
1598 )
1599 leader_stream = _attach_live_stream(leader, session)
1600 child_stream = _attach_live_stream(child, session)
1601 leader._attr_group_members = ["leader", "child"]
1602 leader._attr_playback_state = PlaybackState.PLAYING
1603 child._attr_playback_state = PlaybackState.PLAYING
1604 lookup = {"leader": leader, "child": child}
1605 for player in (leader, child):
1606 players = _players_mock(player)
1607 players.get_player.side_effect = lookup.get
1608 players.iter_players.return_value = [leader, child]
1609
1610 await leader.set_members(player_ids_to_remove=["leader"])
1611
1612 leader_stream.stop.assert_awaited_once()
1613 child_stream.stop.assert_not_awaited()
1614 assert session.sync_clients == [child]
1615 assert leader.group_members == []
1616 # nothing claims the remaining member anymore: its caller picks the new leader
1617 assert child.synced_to is None
1618
1619
1620@pytest.mark.asyncio
1621async def test_ungroup_on_a_sync_leader_dissolves_the_whole_group() -> None:
1622 """
1623 Ungrouping a sync leader must release its members, not just the leader itself.
1624
1625 A leader lists itself in group_members, so the default ungroup asks to remove
1626 the leader AND every member in one call.
1627 """
1628 leader = _make_idle_player("leader")
1629 child = _make_idle_player("child")
1630 session = AirPlayStreamSession(
1631 MagicMock(mass=leader.mass), [leader, child], AIRPLAY_PCM_FORMAT, MagicMock()
1632 )
1633 leader_stream = _attach_live_stream(leader, session)
1634 child_stream = _attach_live_stream(child, session)
1635 leader._attr_group_members = ["leader", "child"]
1636 leader._attr_playback_state = PlaybackState.PLAYING
1637 child._attr_playback_state = PlaybackState.PLAYING
1638 lookup = {"leader": leader, "child": child}
1639 for player in (leader, child):
1640 players = _players_mock(player)
1641 players.get_player.side_effect = lookup.get
1642 players.iter_players.return_value = [leader, child]
1643
1644 await leader.ungroup()
1645
1646 assert leader.group_members == []
1647 leader_stream.stop.assert_awaited_once()
1648 child_stream.stop.assert_awaited_once()
1649 assert session.sync_clients == []
1650
1651
1652# --- Device password ---
1653
1654
1655def _set_password_discovery(
1656 player: AirPlayPlayer,
1657 *,
1658 flags: str = "0x0",
1659 pw: str = "",
1660 password: str | None = None,
1661 paired: bool = False,
1662) -> None:
1663 """
1664 Attach an AirPlay 2 + RAOP device announcing password protection.
1665
1666 :param flags: The _airplay service sf/flags bitmask (0x80 marks a password).
1667 :param pw: The legacy ``pw`` boolean published by the _raop service.
1668 :param password: The device password stored in the player config, if any.
1669 :param paired: Whether AirPlay 2 pairing credentials are stored for the device.
1670 """
1671 airplay_info = MagicMock()
1672 airplay_info.decoded_properties = {"features": AP2_FEATURES, "flags": flags}
1673 airplay_info.properties = {b"flags": flags.encode()}
1674 player.airplay_discovery_info = airplay_info
1675 raop_info = MagicMock()
1676 raop_info.decoded_properties = {"pw": pw} if pw else {}
1677 raop_info.properties = {}
1678 player.raop_discovery_info = raop_info
1679 _configure_player(player, {CONF_FORCE_RAOP: False, CONF_PASSWORD: password})
1680 credentials = {CONF_AIRPLAY_CREDENTIALS: "a" * 192} if paired else {}
1681 player.get_setup_value = ( # type: ignore[method-assign]
1682 lambda key, default=None: credentials.get(key, default)
1683 )
1684
1685
1686@pytest.mark.asyncio
1687async def test_password_entry_is_never_offered_in_the_settings(
1688 airplay_player: AirPlayPlayer,
1689) -> None:
1690 """The password is storage only: the setup flow is the sole way to enter it."""
1691 _set_password_discovery(airplay_player, flags="0x80")
1692 assert airplay_player.password_required is True
1693
1694 entries = await airplay_player.get_config_entries()
1695 entry = next(entry for entry in entries if entry.key == CONF_PASSWORD)
1696 assert entry.hidden is True
1697
1698
1699@pytest.mark.parametrize(
1700 ("flags", "pw"),
1701 [
1702 # AirPlay 2 announces password protection through the flags bit...
1703 ("0x80", ""),
1704 # ...a legacy RAOP receiver through the classic pw boolean
1705 ("0x0", "true"),
1706 ],
1707)
1708def test_announced_password_without_one_stored_needs_setup(
1709 airplay_player: AirPlayPlayer, flags: str, pw: str
1710) -> None:
1711 """A device that asks for a password it never got must be set up first."""
1712 _set_password_discovery(airplay_player, flags=flags, pw=pw)
1713
1714 assert airplay_player.password_required is True
1715 assert airplay_player.needs_setup is True
1716 assert airplay_player.setup_reason == "password_required"
1717
1718
1719def test_apple_tv_password_bit_alone_does_not_need_setup(airplay_player: AirPlayPlayer) -> None:
1720 """Apple TVs raise the generic password bit at all times; it means nothing there."""
1721 # a paired Apple TV without a password set must not be sent back into setup
1722 _set_password_discovery(airplay_player, flags="0x4c4", paired=True)
1723 airplay_player.device_info.model = "AppleTV14,1"
1724
1725 assert airplay_player.password_required is False
1726 assert airplay_player.needs_setup is False
1727
1728
1729def test_apple_tv_with_a_password_set_needs_setup(airplay_player: AirPlayPlayer) -> None:
1730 """The tvOS-specific flags bit is the Apple TV's only password announcement."""
1731 _set_password_discovery(airplay_player, flags="0x14c4")
1732 airplay_player.device_info.model = "AppleTV11,1"
1733
1734 assert airplay_player.password_required is True
1735 assert airplay_player.needs_setup is True
1736 assert airplay_player.setup_reason == "password_required"
1737
1738
1739@pytest.mark.parametrize(
1740 ("flags", "pw", "paired"),
1741 [
1742 # AirPlay 2 collects the password as part of pairing, so it ends up with both
1743 ("0x80", "", True),
1744 # a legacy RAOP receiver has no pairing at all: the password is enough
1745 ("0x0", "true", False),
1746 ],
1747)
1748def test_stored_password_clears_the_setup_requirement(
1749 airplay_player: AirPlayPlayer, flags: str, pw: str, paired: bool
1750) -> None:
1751 """Once the password is stored the player is ready to use again."""
1752 _set_password_discovery(airplay_player, flags=flags, pw=pw, password="hunter2", paired=paired)
1753
1754 assert airplay_player.needs_setup is False
1755 assert airplay_player.setup_reason is None
1756
1757
1758def test_rejected_password_marker_forces_setup(airplay_player: AirPlayPlayer) -> None:
1759 """A password the device rejected sends an otherwise ready player back into setup."""
1760 # the migration case: a paired device that gained password protection later
1761 _set_password_discovery(airplay_player, flags="0x80", password="wrong", paired=True)
1762 ready_before = airplay_player.needs_setup
1763
1764 airplay_player.set_password_invalid(True)
1765
1766 assert ready_before is False
1767 assert airplay_player.password_invalid is True
1768 assert airplay_player.needs_setup is True
1769 assert airplay_player.setup_reason == "password_required"
1770
1771
1772def test_rejected_password_marker_survives_a_restart(airplay_player: AirPlayPlayer) -> None:
1773 """The marker is persisted as a raw player config value, not just in memory."""
1774 _set_password_discovery(airplay_player, flags="0x80", password="wrong", paired=True)
1775
1776 airplay_player.set_password_invalid(True)
1777
1778 airplay_player.mass.config.set_raw_player_config_value.assert_called_once_with( # type: ignore[attr-defined]
1779 "test_player", CONF_PASSWORD_INVALID, True
1780 )
1781
1782
1783def test_clearing_the_marker_only_writes_when_it_was_set(airplay_player: AirPlayPlayer) -> None:
1784 """Every successful connect clears the marker, but must not write the config."""
1785 _set_password_discovery(airplay_player, flags="0x80", password="hunter2", paired=True)
1786 set_raw = airplay_player.mass.config.set_raw_player_config_value
1787
1788 airplay_player.set_password_invalid(False)
1789 set_raw.assert_not_called() # type: ignore[attr-defined]
1790
1791 airplay_player.set_password_invalid(True)
1792 assert airplay_player.needs_setup is True
1793 airplay_player.set_password_invalid(False)
1794
1795 assert airplay_player.password_invalid is False
1796 assert airplay_player.needs_setup is False
1797
1798
1799def test_rejected_password_is_published_to_clients(airplay_player: AirPlayPlayer) -> None:
1800 """The new setup requirement must reach the wire state, not just the property."""
1801 _set_password_discovery(airplay_player, flags="0x80", password="wrong", paired=True)
1802 airplay_player.update_state()
1803 before = airplay_player.state
1804 assert before.needs_setup is False
1805 assert before.available is True
1806
1807 airplay_player.set_password_invalid(True)
1808
1809 # needs_setup/setup_reason are part of the player's own state inputs, so the
1810 # update is neither short-circuited nor left to the next unrelated update
1811 after = airplay_player.state
1812 assert after.needs_setup is True
1813 assert after.setup_reason == "password_required"
1814 assert after.available is False
1815 changed = airplay_player.mass.players.signal_player_state_update.call_args[0][1] # type: ignore[attr-defined]
1816 assert "needs_setup" in changed
1817
1818
1819def test_pin_pairing_keeps_its_own_setup_reason(airplay_player: AirPlayPlayer) -> None:
1820 """A device that only needs PIN pairing is not reported as a password problem."""
1821 airplay_info = MagicMock()
1822 airplay_info.decoded_properties = {"features": AP2_FEATURES}
1823 airplay_info.properties = {b"flags": b"0x8"}
1824 airplay_player.airplay_discovery_info = airplay_info
1825 airplay_player.get_setup_value = lambda key, default=None: default # type: ignore[method-assign] # noqa: ARG005
1826
1827 assert airplay_player.needs_setup is True
1828 assert airplay_player.setup_reason == "pairing_required"
1829