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