/
/
1"""Unit tests for the native AirPlay announcement orchestration."""
2
3import asyncio
4import logging
5import time
6from collections.abc import AsyncGenerator, Iterator
7from typing import Any
8from unittest.mock import AsyncMock, MagicMock, patch
9
10import pytest
11from music_assistant_models.enums import ContentType, PlaybackState
12from music_assistant_models.errors import PlayerCommandFailed
13from music_assistant_models.media_items import AudioFormat
14
15from music_assistant.providers.airplay import announce
16from music_assistant.providers.airplay.constants import (
17 AIRPLAY_ANNOUNCE_AT_MARGIN_MS,
18 AIRPLAY_ANNOUNCE_DUCK_DB,
19 AIRPLAY_ANNOUNCE_DUCK_TAIL_S,
20 AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS,
21 AIRPLAY_PCM_FORMAT,
22)
23
24ANNOUNCE_DATA = {
25 "announcement_url": "http://ma.local/tts.mp3",
26 "pre_announce": True,
27 "pre_announce_url": "http://ma.local/chime.mp3",
28 "announce_player_id": None,
29}
30HIRES_PCM_FORMAT = AudioFormat(content_type=ContentType.PCM_S32LE, sample_rate=48000, bit_depth=24)
31
32
33@pytest.fixture(autouse=True)
34def _zero_restore_pad() -> Iterator[None]:
35 """
36 Zero the audible-end pad for every test.
37
38 Both paths hold the volume restore until the clip's audible end plus this
39 pad - the live path holds its return with it too; the streams below ack a
40 long-past instant, so only the pad would otherwise add real wall-clock time
41 to every test. The tests that assert on that timing override it themselves.
42 """
43 with patch.object(announce, "AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS", 0):
44 yield
45
46
47def _make_render(duration: float = 1.5) -> MagicMock:
48 """Build a mock announcement render that finished with the given duration."""
49 render = MagicMock()
50 render.duration = duration
51 render.wait_finished = AsyncMock(return_value=duration)
52
53 async def get_stream(_output_format: Any) -> AsyncGenerator[bytes]:
54 # non-silent, so a clip is distinguishable from the silence around it
55 yield b"\xff" * 64
56
57 render.get_stream = get_stream
58 return render
59
60
61def _make_stream(
62 pcm_format: AudioFormat = AIRPLAY_PCM_FORMAT,
63 ack: tuple[int, int] | None = (1, 1),
64) -> MagicMock:
65 """
66 Build a mock member stream whose announce arm resolves with the given ack.
67
68 The default ack reports a long-past audible instant, so the audible-end
69 hold at the end of the live path resolves without waiting.
70 """
71 stream = MagicMock()
72 stream.running = True
73 stream.connected = True
74 stream.pcm_format = pcm_format
75 stream.warm_lead_ms = 0
76 stream.latency_lead_ms = 0
77 stream.announce = AsyncMock(return_value=True)
78 stream.wait_announce_started = AsyncMock(return_value=ack)
79 stream.wait_announce_done = AsyncMock(return_value=True)
80 return stream
81
82
83def _make_player(player_id: str, stream: MagicMock | None = None) -> MagicMock:
84 """Build a mock AirPlay player for announcement orchestration tests."""
85 player = MagicMock()
86 player.player_id = player_id
87 player.display_name = player_id
88 player.synced_to = None
89 player.state.active_group = None
90 player.protocol_parent_id = None
91 player.playback_state = PlaybackState.PLAYING
92 player.volume_level = 30
93 player.volume_set = AsyncMock()
94 player.stream = stream
95 player._lock = asyncio.Lock()
96 player.logger = logging.getLogger("test.airplay.announce")
97 player.mass.create_task = MagicMock(
98 side_effect=lambda coro, *_args, **_kwargs: asyncio.get_running_loop().create_task(coro)
99 )
100 player.provider._announce_plans = {}
101 player.provider.bridge_manager.get_bridge = MagicMock(return_value=None)
102 renderer = player.mass.streams.announcement_renderer
103 renderer.acquire = MagicMock(return_value=_make_render())
104 renderer.release = AsyncMock()
105 return player
106
107
108def _make_playing_group(*streams: MagicMock) -> list[MagicMock]:
109 """Build a playing session of one member per given stream; first one leads."""
110 members = [_make_player(f"member_{i}", stream=stream) for i, stream in enumerate(streams)]
111 session = MagicMock()
112 session.sync_clients = members
113 for member in members:
114 member.stream.session = session
115 return members
116
117
118def _make_announcement() -> MagicMock:
119 """Build the announcement PlayerMedia handed down by the player controller."""
120 return MagicMock(custom_data=dict(ANNOUNCE_DATA))
121
122
123def test_member_span_prefers_the_warm_lead() -> None:
124 """A splice-timeline member's span is its warm lead, not the device lead."""
125 stream = _make_stream()
126 stream.warm_lead_ms = 600
127 stream.latency_lead_ms = 1900
128
129 assert announce._member_span_ms(stream) == 600
130
131
132def test_member_span_uses_the_device_lead_without_a_warm_lead() -> None:
133 """Without a warm lead, the reported device lead bounds the delivery head."""
134 stream = _make_stream()
135 stream.latency_lead_ms = 1900
136
137 assert announce._member_span_ms(stream) == 1900
138
139
140def test_member_span_falls_back_when_nothing_was_reported() -> None:
141 """Both leads at 0 mean unreported: assume the binary's default playback lead."""
142 assert announce._member_span_ms(_make_stream()) == AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS
143
144
145def test_shared_instant_covers_the_slowest_member() -> None:
146 """The shared instant sits past the LARGEST member span plus the fan-out margin."""
147 fast = _make_stream()
148 fast.warm_lead_ms = 600
149 slow = _make_stream()
150 slow.latency_lead_ms = 1900
151 unreported = _make_stream()
152
153 before_ms = int(time.time() * 1000)
154 at_unix_ms = announce._shared_announce_instant([fast, slow, unreported])
155 after_ms = time.time() * 1000
156
157 # the unreported member's fallback span (2000) is the largest of the three
158 expected_lead = AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS + AIRPLAY_ANNOUNCE_AT_MARGIN_MS
159 assert before_ms + expected_lead <= at_unix_ms <= after_ms + expected_lead
160
161
162@pytest.mark.asyncio
163async def test_live_announcement_arms_every_member_at_one_shared_instant() -> None:
164 """A playing session arms every member with the same instant, duck and clip file."""
165 streams = [_make_stream(), _make_stream()]
166 members = _make_playing_group(*streams)
167 leader = members[0]
168
169 with patch.object(announce, "_announce_with_session", new_callable=AsyncMock) as session_path:
170 await announce.play_announcement(leader, _make_announcement(), None)
171
172 session_path.assert_not_awaited()
173 arms = [stream.announce.call_args for stream in streams]
174 for arm in arms:
175 assert arm is not None
176 # one shared instant and the default duck for every member
177 assert len({arm.args[1] for arm in arms}) == 1
178 assert {arm.args[2] for arm in arms} == {AIRPLAY_ANNOUNCE_DUCK_DB}
179 # both members share one stdin format, so they share one clip file
180 assert len({arm.args[0] for arm in arms}) == 1
181 assert arms[0].args[0].endswith(".pcm")
182 for stream in streams:
183 stream.wait_announce_done.assert_awaited_once()
184 leader.mass.streams.announcement_renderer.release.assert_awaited_once()
185
186
187@pytest.mark.asyncio
188async def test_live_announcement_renders_one_clip_per_distinct_format() -> None:
189 """Members on different stdin formats each get a clip in exactly their format."""
190 streams = [_make_stream(), _make_stream(pcm_format=HIRES_PCM_FORMAT)]
191 members = _make_playing_group(*streams)
192
193 with patch.object(announce, "_announce_with_session", new_callable=AsyncMock):
194 await announce.play_announcement(members[0], _make_announcement(), None)
195
196 clip_paths = {stream.announce.call_args.args[0] for stream in streams}
197 assert len(clip_paths) == 2
198
199
200@pytest.mark.asyncio
201async def test_idle_player_takes_the_session_path() -> None:
202 """Without live playback the announcement runs as its own stream session."""
203 player = _make_player("solo")
204 player.playback_state = PlaybackState.IDLE
205 announcement = _make_announcement()
206
207 with patch.object(announce, "_announce_with_session", new_callable=AsyncMock) as session_path:
208 await announce.play_announcement(player, announcement, 40)
209
210 session_path.assert_awaited_once()
211 args = session_path.call_args.args
212 assert args[0] is player
213 assert args[1] is announcement
214 assert args[3] == 1.5 # the render's exact duration bounds the session waits
215 assert args[4] == 40
216
217
218@pytest.mark.asyncio
219async def test_no_member_arming_fails_without_killing_the_music() -> None:
220 """
221 Live members that never arm the clip fail the announcement, not the music.
222
223 An outdated cliairplay silently ignores the arm command; a fallback session
224 would stop the user's playing session over a clip that could not be mixed.
225 """
226 streams = [_make_stream(ack=None), _make_stream(ack=None)]
227 members = _make_playing_group(*streams)
228 live_session = streams[0].session
229 live_session.stop = AsyncMock()
230
231 with (
232 patch.object(announce, "_announce_with_session", new_callable=AsyncMock) as session_path,
233 pytest.raises(PlayerCommandFailed, match="may not support announcements"),
234 ):
235 await announce.play_announcement(members[0], _make_announcement(), None)
236
237 for stream in streams:
238 stream.announce.assert_awaited_once()
239 stream.wait_announce_done.assert_not_awaited()
240 session_path.assert_not_awaited()
241 live_session.stop.assert_not_awaited()
242 members[0].mass.streams.announcement_renderer.release.assert_awaited_once()
243
244
245@pytest.mark.asyncio
246async def test_partial_success_warns_and_does_not_fall_back(
247 caplog: pytest.LogCaptureFixture,
248) -> None:
249 """One member playing the clip is a success; the members that did not are named."""
250 streams = [_make_stream(), _make_stream(ack=None)]
251 members = _make_playing_group(*streams)
252
253 with (
254 patch.object(announce, "_announce_with_session", new_callable=AsyncMock) as session_path,
255 caplog.at_level(logging.WARNING),
256 ):
257 await announce.play_announcement(members[0], _make_announcement(), None)
258
259 session_path.assert_not_awaited()
260 assert "member_1" in caplog.text
261 streams[0].wait_announce_done.assert_awaited_once()
262 streams[1].wait_announce_done.assert_not_awaited()
263
264
265@pytest.mark.asyncio
266async def test_volume_is_scheduled_on_the_acked_instant() -> None:
267 """The volume bump lands at the acked audible instant, the restore after the clip."""
268 ack_at_unix_ms = int(time.time() * 1000) + 200
269 # the acked duration includes the 1s ducked-silence tail: 0.8s of content,
270 # which keeps the bias out of the short-clip cap while the audible-end hold
271 # this call ends on stays short
272 stream = _make_stream(ack=(ack_at_unix_ms, 1800))
273 members = _make_playing_group(stream)
274 member = members[0]
275 member.volume_level = 30
276 # both delays count down from the moment they are scheduled, which the
277 # clock reads either side of it bracket exactly: the audible-end hold this
278 # call ends on runs for seconds past that moment
279 scheduled_at: list[float] = []
280 member.mass.call_later = MagicMock(
281 side_effect=lambda *_args, **_kwargs: scheduled_at.append(time.time())
282 )
283
284 before_s = time.time()
285 with patch.object(announce, "_announce_with_session", new_callable=AsyncMock):
286 await announce.play_announcement(member, _make_announcement(), 55)
287
288 scheduled = member.mass.call_later.call_args_list
289 assert len(scheduled) == 2
290 bump, restore = scheduled
291 assert bump.args[1:] == (member.volume_set, 55)
292 assert restore.args[1:] == (member.volume_set, 30)
293 # the bump lands on what was left of the acked instant when it was
294 # scheduled (0.2s out here), plus the 0.3s into-the-clip bias; the restore
295 # follows the CONTENT length (the acked duration minus the silence tail)
296 # plus the (zeroed) pad, so it lands inside the ducked cushion - 0.5s
297 # after the bump here
298 left_at_least = max(0.0, ack_at_unix_ms / 1000 - scheduled_at[0])
299 left_at_most = max(0.0, ack_at_unix_ms / 1000 - before_s)
300 assert left_at_least + 0.3 <= bump.args[0] <= left_at_most + 0.3
301 assert restore.args[0] - bump.args[0] == pytest.approx(0.5)
302
303
304def test_member_duck_compensates_the_volume_bump() -> None:
305 """
306 The duck deepens by exactly the device-volume bump so the music never rises.
307
308 38 -> 61 volume points is +6.9 dB on the AirPlay dB scale; the -18 dB duck
309 becomes -24.9 dB so the music's perceived level stays at the configured
310 duck depth (the regression heard as "the music was not ducked"). A bump
311 down shallows it symmetrically, and without a bump the base duck applies.
312 """
313 member = _make_player("m")
314 member.volume_level = 38
315 assert announce._member_duck_db(member, 61) == pytest.approx(-24.9)
316 assert announce._member_duck_db(member, 18) == pytest.approx(-12.0)
317 assert announce._member_duck_db(member, None) == pytest.approx(-18.0)
318 assert announce._member_duck_db(member, 38) == pytest.approx(-18.0)
319 # extreme bumps clamp to the binary's usable range (never boost the music)
320 member.volume_level = 0
321 assert announce._member_duck_db(member, 100) == pytest.approx(-48.0)
322 assert announce._member_duck_db(member, 0) == pytest.approx(-18.0)
323 member.volume_level = 100
324 assert announce._member_duck_db(member, 0) == pytest.approx(0.0)
325
326
327@pytest.mark.asyncio
328async def test_no_volume_level_leaves_the_volume_alone() -> None:
329 """Without an announcement volume nothing is scheduled on any member."""
330 members = _make_playing_group(_make_stream())
331
332 with patch.object(announce, "_announce_with_session", new_callable=AsyncMock):
333 await announce.play_announcement(members[0], _make_announcement(), None)
334
335 members[0].mass.call_later.assert_not_called()
336
337
338@pytest.mark.asyncio
339async def test_session_path_plays_the_clip_and_restores_the_volume() -> None:
340 """The dedicated session serves the clip to the configured group at the given volume."""
341 player = _make_player("solo")
342 player.playback_state = PlaybackState.IDLE
343 player.volume_level = 25
344 player._get_sync_clients = MagicMock(return_value=[player])
345 player._get_session_pcm_format = AsyncMock(return_value=AIRPLAY_PCM_FORMAT)
346 announcement = _make_announcement()
347 render = _make_render(duration=0.01)
348 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
349 events: list[tuple[str, float]] = []
350
351 with (
352 patch.object(announce, "AirPlayStreamSession") as session_cls,
353 patch.object(announce, "AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS", 100),
354 patch.object(announce, "AIRPLAY_ANNOUNCE_SESSION_DRAIN_S", 0.4),
355 ):
356 started = time.monotonic()
357 player.volume_set = AsyncMock(
358 side_effect=lambda level: events.append((f"volume={level}", time.monotonic() - started))
359 )
360 session = session_cls.return_value
361 session.start = AsyncMock()
362 session.stop = AsyncMock(
363 side_effect=lambda: events.append(("stop", time.monotonic() - started))
364 )
365 session.start_time = 0.0
366 await announce.play_announcement(player, announcement, 60)
367
368 assert session_cls.call_args.args == (
369 player.provider,
370 [player],
371 AIRPLAY_PCM_FORMAT,
372 announcement,
373 )
374 session.start.assert_awaited_once()
375 session.stop.assert_awaited_once()
376 # An AirPlay volume only reaches the receiver over a running stream, so the
377 # restore has to land on the clip's audible end (+ the 0.1s pad), well
378 # inside the 0.4s drain the session is stopped after.
379 assert [name for name, _ in events] == ["volume=60", "volume=25", "stop"]
380 _, restored_at = events[1]
381 _, stopped_at = events[2]
382 assert 0.1 <= restored_at < 0.3
383 assert stopped_at >= 0.4
384 # the player ends idle without still showing media (like player.stop())
385 assert player._attr_current_media is None
386 player.update_state.assert_called()
387
388
389@pytest.mark.asyncio
390async def test_session_path_restores_the_volume_when_the_clip_is_cut_short() -> None:
391 """An announcement cancelled mid-clip hands the speaker back its own volume."""
392 player = _make_player("solo")
393 player.playback_state = PlaybackState.IDLE
394 player.volume_level = 25
395 player._get_sync_clients = MagicMock(return_value=[player])
396 player._get_session_pcm_format = AsyncMock(return_value=AIRPLAY_PCM_FORMAT)
397 render = _make_render(duration=30)
398 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
399 events: list[str] = []
400 player.volume_set = AsyncMock(side_effect=lambda level: events.append(f"volume={level}"))
401
402 with patch.object(announce, "AirPlayStreamSession") as session_cls:
403 started = asyncio.Event()
404 session = session_cls.return_value
405 session.start = AsyncMock(side_effect=lambda _source: started.set())
406 session.stop = AsyncMock(side_effect=lambda: events.append("stop"))
407 session.start_time = 0.0
408 announcing = asyncio.create_task(
409 announce.play_announcement(player, _make_announcement(), 60)
410 )
411 # the session is up; the cancel lands in the clip wait that follows
412 await started.wait()
413 announcing.cancel()
414 with pytest.raises(asyncio.CancelledError):
415 await announcing
416
417 # the session is still up here, so the restore reaches the receiver
418 assert events == ["volume=60", "volume=25", "stop"]
419
420
421@pytest.mark.asyncio
422async def test_session_path_serves_the_clip_with_its_silence_tail() -> None:
423 """The dedicated session keeps feeding past the clip, so the volume restore lands in time."""
424 player = _make_player("solo")
425 player.playback_state = PlaybackState.IDLE
426 player._get_sync_clients = MagicMock(return_value=[player])
427 player._get_session_pcm_format = AsyncMock(return_value=HIRES_PCM_FORMAT)
428 render = _make_render(duration=0.01)
429 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
430 served = bytearray()
431
432 with (
433 patch.object(announce, "AirPlayStreamSession") as session_cls,
434 patch.object(announce, "AIRPLAY_ANNOUNCE_SESSION_DRAIN_S", 0.0),
435 ):
436
437 async def start(source: AsyncGenerator[bytes]) -> None:
438 async for chunk in source:
439 served.extend(chunk)
440
441 session = session_cls.return_value
442 session.start = AsyncMock(side_effect=start)
443 session.stop = AsyncMock()
444 session.start_time = 0.0
445 await announce.play_announcement(player, _make_announcement(), None)
446
447 # the tail is sized on the content type: this format carries 24 bit over an
448 # s32le wire, so a bit_depth-derived size would come out a quarter short
449 tail_bytes = (
450 int(HIRES_PCM_FORMAT.sample_rate * AIRPLAY_ANNOUNCE_DUCK_TAIL_S)
451 * 4
452 * HIRES_PCM_FORMAT.channels
453 )
454 assert served == b"\xff" * 64 + bytes(tail_bytes)
455
456
457@pytest.mark.asyncio
458async def test_session_path_stops_a_parked_session_first() -> None:
459 """A parked (paused) session is stopped before the dedicated announcement session."""
460 parked_stream = _make_stream()
461 player = _make_player("solo", stream=parked_stream)
462 player.playback_state = PlaybackState.PAUSED
463 player._get_sync_clients = MagicMock(return_value=[player])
464 player._get_session_pcm_format = AsyncMock(return_value=AIRPLAY_PCM_FORMAT)
465 parked_session = parked_stream.session
466 parked_session.stop = AsyncMock()
467 render = _make_render(duration=0.01)
468 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
469
470 with (
471 patch.object(announce, "AirPlayStreamSession") as session_cls,
472 patch.object(announce, "AIRPLAY_ANNOUNCE_SESSION_DRAIN_S", 0.0),
473 ):
474 session = session_cls.return_value
475 session.start = AsyncMock()
476 session.stop = AsyncMock()
477 session.start_time = 0.0
478 await announce.play_announcement(player, _make_announcement(), None)
479
480 parked_session.stop.assert_awaited_once()
481 assert player.stream is None
482 session.start.assert_awaited_once()
483
484
485@pytest.mark.asyncio
486async def test_group_entity_fanout_arms_each_member_at_one_shared_instant() -> None:
487 """
488 A group-entity fan-out arms each member once, at one shared instant.
489
490 The controller forwards a group-entity announcement per member; each call
491 arms only its OWN member, and all of them share one audible instant through
492 the provider's plan registry, so every room renders in sync.
493 """
494 streams = [_make_stream(), _make_stream(), _make_stream()]
495 members = _make_playing_group(*streams)
496 leader, member_1, member_2 = members
497 provider = leader.provider
498 render = _make_render()
499 for member in members:
500 member.provider = provider
501 member.state.active_group = "syncgroup_1"
502 if member is not leader:
503 member.synced_to = leader.player_id
504 member.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
505 # the second member reports a span past the fallback the others assume:
506 # the shared instant must clear the LARGEST one for every sibling arm
507 largest_span_ms = AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS + 1000
508 streams[1].latency_lead_ms = largest_span_ms
509 announcement = _make_announcement()
510
511 before_ms = int(time.time() * 1000)
512 await asyncio.gather(
513 announce.play_announcement(leader, announcement, None),
514 announce.play_announcement(member_1, announcement, None),
515 announce.play_announcement(member_2, announcement, None),
516 )
517 after_ms = time.time() * 1000
518
519 instants = set()
520 for stream in streams:
521 stream.announce.assert_awaited_once()
522 stream.wait_announce_done.assert_awaited_once()
523 instants.add(stream.announce.await_args.args[1])
524 assert len(instants) == 1
525 # the instant is computed inside the call, so the clock reads either side
526 # of it bracket the lead however long the fan-out itself takes
527 expected_lead = largest_span_ms + AIRPLAY_ANNOUNCE_AT_MARGIN_MS
528 assert before_ms + expected_lead <= next(iter(instants)) <= after_ms + expected_lead
529
530
531@pytest.mark.asyncio
532async def test_group_entity_session_leader_announces_alone() -> None:
533 """
534 A group entity's session leader addressed individually announces alone.
535
536 The entity itself is the whole-group handle, so leading the underlying
537 session does not widen an individual announcement.
538 """
539 streams = [_make_stream(), _make_stream()]
540 members = _make_playing_group(*streams)
541 leader = members[0]
542 leader.state.active_group = "syncgroup_1"
543 members[1].synced_to = leader.player_id
544
545 await announce.play_announcement(leader, _make_announcement(), None)
546
547 streams[0].announce.assert_awaited_once()
548 streams[1].announce.assert_not_awaited()
549
550
551@pytest.mark.asyncio
552async def test_protocol_child_reads_group_ownership_from_its_parent() -> None:
553 """
554 A protocol child leading a syncgroup's session still announces alone.
555
556 Protocol players never carry active_group themselves - the model keeps the
557 group state on the device player they render for - so the whole-group
558 handle is found through the protocol parent (the regression heard as an
559 individual announcement playing on the whole syncgroup).
560 """
561 streams = [_make_stream(), _make_stream()]
562 members = _make_playing_group(*streams)
563 leader = members[0]
564 leader.protocol_parent_id = "milo_parent"
565 parent = MagicMock()
566 parent.state.active_group = "syncgroup_1"
567 leader.mass.players.get_player = MagicMock(return_value=parent)
568 members[1].synced_to = leader.player_id
569
570 await announce.play_announcement(leader, _make_announcement(), None)
571
572 streams[0].announce.assert_awaited_once()
573 streams[1].announce.assert_not_awaited()
574
575
576@pytest.mark.asyncio
577async def test_member_announced_directly_arms_itself() -> None:
578 """A direct announcement to one synced member mixes on just that member."""
579 stream = _make_stream()
580 members = _make_playing_group(stream)
581 member = members[0]
582 member.synced_to = "some_leader"
583
584 await announce.play_announcement(member, _make_announcement(), None)
585
586 stream.announce.assert_awaited_once()
587
588
589@pytest.mark.asyncio
590async def test_synced_member_without_live_playback_is_refused() -> None:
591 """
592 A parked group member must not announce by tearing down the shared session.
593
594 Its stream belongs to the leader's parked session; stopping that for a
595 single-member announcement would silence every room in the group.
596 """
597 parked_stream = _make_stream()
598 members = _make_playing_group(parked_stream)
599 member = members[0]
600 member.synced_to = "some_leader"
601 member.playback_state = PlaybackState.PAUSED
602 parked_session = parked_stream.session
603 parked_session.stop = AsyncMock()
604
605 with pytest.raises(PlayerCommandFailed, match="without live playback"):
606 await announce.play_announcement(member, _make_announcement(), None)
607
608 parked_session.stop.assert_not_awaited()
609
610
611@pytest.mark.asyncio
612async def test_return_holds_until_the_audible_end() -> None:
613 """
614 The call returns only once the clip is audibly over, not when it is mixed.
615
616 announce_done reports MIX completion at the delivery head - ahead of
617 audibility - and the caller re-mutes muted players the moment this returns.
618 """
619 # taken WITH the wall clock the ack is built from, so the hold is measured
620 # from that same instant however slow the setup below runs
621 started = time.monotonic()
622 now_unix_ms = int(time.time() * 1000)
623 stream = _make_stream(ack=(now_unix_ms + 250, 100))
624 members = _make_playing_group(stream)
625
626 with patch.object(announce, "AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS", 100):
627 await announce.play_announcement(members[0], _make_announcement(), None)
628 elapsed = time.monotonic() - started
629
630 # audible end = acked instant + clip duration (0.35s out) + the 0.1s pad
631 assert elapsed >= 0.4
632
633
634@pytest.mark.asyncio
635async def test_bridged_player_with_live_stream_arms_itself() -> None:
636 """A Sendspin-bridged player mixes the clip into the bridge-owned stream."""
637 stream = _make_stream()
638 stream.session = None
639 player = _make_player("bridged", stream=stream)
640 bridge = MagicMock(owns_airplay_stream=True)
641 player.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
642
643 await announce.play_announcement(player, _make_announcement(), None)
644
645 stream.announce.assert_awaited_once()
646 stream.wait_announce_done.assert_awaited_once()
647
648
649@pytest.mark.asyncio
650async def test_bridge_configured_player_mixes_over_its_own_session() -> None:
651 """
652 A bridge that is merely configured never blocks the live mix.
653
654 The regression this pins: a player with a Sendspin bridge set up but
655 playing its own (session-backed) AirPlay stream mixes the clip like any
656 unbridged player - the idle bridge is a bystander.
657 """
658 (member,) = _make_playing_group(_make_stream())
659 bridge = MagicMock(owns_airplay_stream=False)
660 member.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
661
662 await announce.play_announcement(member, _make_announcement(), None)
663
664 member.stream.announce.assert_awaited_once()
665 member.stream.wait_announce_done.assert_awaited_once()
666
667
668@pytest.mark.asyncio
669async def test_bridge_streaming_player_never_runs_a_session_fallback() -> None:
670 """A player whose bridge owns the live stream fails instead of seizing the device."""
671 player = _make_player("bridged")
672 player.playback_state = PlaybackState.IDLE
673 bridge = MagicMock(owns_airplay_stream=True)
674 player.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
675
676 with (
677 patch.object(announce, "AirPlayStreamSession") as session_cls,
678 pytest.raises(PlayerCommandFailed, match="Sendspin bridge"),
679 ):
680 await announce.play_announcement(player, _make_announcement(), None)
681
682 session_cls.assert_not_called()
683
684
685@pytest.mark.asyncio
686async def test_announcement_without_data_is_refused() -> None:
687 """An announcement without its announce data cannot be rendered."""
688 player = _make_player("solo")
689
690 with pytest.raises(PlayerCommandFailed, match="carries no announcement data"):
691 await announce.play_announcement(player, MagicMock(custom_data=None), None)
692
693
694@pytest.mark.asyncio
695async def test_announcement_without_audio_plays_nothing(
696 caplog: pytest.LogCaptureFixture,
697) -> None:
698 """A render that produced no audio is skipped, releasing the render regardless."""
699 stream = _make_stream()
700 members = _make_playing_group(stream)
701 player = members[0]
702 render = _make_render(duration=0.0)
703 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
704
705 with caplog.at_level(logging.WARNING):
706 await announce.play_announcement(player, _make_announcement(), None)
707
708 stream.announce.assert_not_awaited()
709 assert "produced no audio" in caplog.text
710 player.mass.streams.announcement_renderer.release.assert_awaited_once()
711