/
/
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: 2s of content
270 stream = _make_stream(ack=(ack_at_unix_ms, 3000))
271 members = _make_playing_group(stream)
272 member = members[0]
273 member.volume_level = 30
274
275 with patch.object(announce, "_announce_with_session", new_callable=AsyncMock):
276 await announce.play_announcement(member, _make_announcement(), 55)
277
278 scheduled = member.mass.call_later.call_args_list
279 assert len(scheduled) == 2
280 bump, restore = scheduled
281 assert bump.args[1:] == (member.volume_set, 55)
282 assert restore.args[1:] == (member.volume_set, 30)
283 # the bump derives from the acked instant (~0.2s out) plus the 0.3s
284 # into-the-clip bias; the restore follows the CONTENT length (the acked
285 # duration minus the silence tail) plus the (zeroed) pad, so it lands
286 # inside the ducked cushion - 1.7s after the bump here
287 assert 0.2 < bump.args[0] <= 0.5
288 assert restore.args[0] - bump.args[0] == pytest.approx(1.7)
289
290
291def test_member_duck_compensates_the_volume_bump() -> None:
292 """
293 The duck deepens by exactly the device-volume bump so the music never rises.
294
295 38 -> 61 volume points is +6.9 dB on the AirPlay dB scale; the -18 dB duck
296 becomes -24.9 dB so the music's perceived level stays at the configured
297 duck depth (the regression heard as "the music was not ducked"). A bump
298 down shallows it symmetrically, and without a bump the base duck applies.
299 """
300 member = _make_player("m")
301 member.volume_level = 38
302 assert announce._member_duck_db(member, 61) == pytest.approx(-24.9)
303 assert announce._member_duck_db(member, 18) == pytest.approx(-12.0)
304 assert announce._member_duck_db(member, None) == pytest.approx(-18.0)
305 assert announce._member_duck_db(member, 38) == pytest.approx(-18.0)
306 # extreme bumps clamp to the binary's usable range (never boost the music)
307 member.volume_level = 0
308 assert announce._member_duck_db(member, 100) == pytest.approx(-48.0)
309 assert announce._member_duck_db(member, 0) == pytest.approx(-18.0)
310 member.volume_level = 100
311 assert announce._member_duck_db(member, 0) == pytest.approx(0.0)
312
313
314@pytest.mark.asyncio
315async def test_no_volume_level_leaves_the_volume_alone() -> None:
316 """Without an announcement volume nothing is scheduled on any member."""
317 members = _make_playing_group(_make_stream())
318
319 with patch.object(announce, "_announce_with_session", new_callable=AsyncMock):
320 await announce.play_announcement(members[0], _make_announcement(), None)
321
322 members[0].mass.call_later.assert_not_called()
323
324
325@pytest.mark.asyncio
326async def test_session_path_plays_the_clip_and_restores_the_volume() -> None:
327 """The dedicated session serves the clip to the configured group at the given volume."""
328 player = _make_player("solo")
329 player.playback_state = PlaybackState.IDLE
330 player.volume_level = 25
331 player._get_sync_clients = MagicMock(return_value=[player])
332 player._get_session_pcm_format = AsyncMock(return_value=AIRPLAY_PCM_FORMAT)
333 announcement = _make_announcement()
334 render = _make_render(duration=0.01)
335 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
336 events: list[tuple[str, float]] = []
337
338 with (
339 patch.object(announce, "AirPlayStreamSession") as session_cls,
340 patch.object(announce, "AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS", 100),
341 patch.object(announce, "AIRPLAY_ANNOUNCE_SESSION_DRAIN_S", 0.4),
342 ):
343 started = time.monotonic()
344 player.volume_set = AsyncMock(
345 side_effect=lambda level: events.append((f"volume={level}", time.monotonic() - started))
346 )
347 session = session_cls.return_value
348 session.start = AsyncMock()
349 session.stop = AsyncMock(
350 side_effect=lambda: events.append(("stop", time.monotonic() - started))
351 )
352 session.start_time = 0.0
353 await announce.play_announcement(player, announcement, 60)
354
355 assert session_cls.call_args.args == (
356 player.provider,
357 [player],
358 AIRPLAY_PCM_FORMAT,
359 announcement,
360 )
361 session.start.assert_awaited_once()
362 session.stop.assert_awaited_once()
363 # An AirPlay volume only reaches the receiver over a running stream, so the
364 # restore has to land on the clip's audible end (+ the 0.1s pad), well
365 # inside the 0.4s drain the session is stopped after.
366 assert [name for name, _ in events] == ["volume=60", "volume=25", "stop"]
367 _, restored_at = events[1]
368 _, stopped_at = events[2]
369 assert 0.1 <= restored_at < 0.3
370 assert stopped_at >= 0.4
371 # the player ends idle without still showing media (like player.stop())
372 assert player._attr_current_media is None
373 player.update_state.assert_called()
374
375
376@pytest.mark.asyncio
377async def test_session_path_restores_the_volume_when_the_clip_is_cut_short() -> None:
378 """An announcement cancelled mid-clip hands the speaker back its own volume."""
379 player = _make_player("solo")
380 player.playback_state = PlaybackState.IDLE
381 player.volume_level = 25
382 player._get_sync_clients = MagicMock(return_value=[player])
383 player._get_session_pcm_format = AsyncMock(return_value=AIRPLAY_PCM_FORMAT)
384 render = _make_render(duration=30)
385 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
386 events: list[str] = []
387 player.volume_set = AsyncMock(side_effect=lambda level: events.append(f"volume={level}"))
388
389 with patch.object(announce, "AirPlayStreamSession") as session_cls:
390 started = asyncio.Event()
391 session = session_cls.return_value
392 session.start = AsyncMock(side_effect=lambda _source: started.set())
393 session.stop = AsyncMock(side_effect=lambda: events.append("stop"))
394 session.start_time = 0.0
395 announcing = asyncio.create_task(
396 announce.play_announcement(player, _make_announcement(), 60)
397 )
398 # the session is up; the cancel lands in the clip wait that follows
399 await started.wait()
400 announcing.cancel()
401 with pytest.raises(asyncio.CancelledError):
402 await announcing
403
404 # the session is still up here, so the restore reaches the receiver
405 assert events == ["volume=60", "volume=25", "stop"]
406
407
408@pytest.mark.asyncio
409async def test_session_path_serves_the_clip_with_its_silence_tail() -> None:
410 """The dedicated session keeps feeding past the clip, so the volume restore lands in time."""
411 player = _make_player("solo")
412 player.playback_state = PlaybackState.IDLE
413 player._get_sync_clients = MagicMock(return_value=[player])
414 player._get_session_pcm_format = AsyncMock(return_value=HIRES_PCM_FORMAT)
415 render = _make_render(duration=0.01)
416 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
417 served = bytearray()
418
419 with (
420 patch.object(announce, "AirPlayStreamSession") as session_cls,
421 patch.object(announce, "AIRPLAY_ANNOUNCE_SESSION_DRAIN_S", 0.0),
422 ):
423
424 async def start(source: AsyncGenerator[bytes]) -> None:
425 async for chunk in source:
426 served.extend(chunk)
427
428 session = session_cls.return_value
429 session.start = AsyncMock(side_effect=start)
430 session.stop = AsyncMock()
431 session.start_time = 0.0
432 await announce.play_announcement(player, _make_announcement(), None)
433
434 # the tail is sized on the content type: this format carries 24 bit over an
435 # s32le wire, so a bit_depth-derived size would come out a quarter short
436 tail_bytes = (
437 int(HIRES_PCM_FORMAT.sample_rate * AIRPLAY_ANNOUNCE_DUCK_TAIL_S)
438 * 4
439 * HIRES_PCM_FORMAT.channels
440 )
441 assert served == b"\xff" * 64 + bytes(tail_bytes)
442
443
444@pytest.mark.asyncio
445async def test_session_path_stops_a_parked_session_first() -> None:
446 """A parked (paused) session is stopped before the dedicated announcement session."""
447 parked_stream = _make_stream()
448 player = _make_player("solo", stream=parked_stream)
449 player.playback_state = PlaybackState.PAUSED
450 player._get_sync_clients = MagicMock(return_value=[player])
451 player._get_session_pcm_format = AsyncMock(return_value=AIRPLAY_PCM_FORMAT)
452 parked_session = parked_stream.session
453 parked_session.stop = AsyncMock()
454 render = _make_render(duration=0.01)
455 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
456
457 with (
458 patch.object(announce, "AirPlayStreamSession") as session_cls,
459 patch.object(announce, "AIRPLAY_ANNOUNCE_SESSION_DRAIN_S", 0.0),
460 ):
461 session = session_cls.return_value
462 session.start = AsyncMock()
463 session.stop = AsyncMock()
464 session.start_time = 0.0
465 await announce.play_announcement(player, _make_announcement(), None)
466
467 parked_session.stop.assert_awaited_once()
468 assert player.stream is None
469 session.start.assert_awaited_once()
470
471
472@pytest.mark.asyncio
473async def test_group_entity_fanout_arms_each_member_at_one_shared_instant() -> None:
474 """
475 A group-entity fan-out arms each member once, at one shared instant.
476
477 The controller forwards a group-entity announcement per member; each call
478 arms only its OWN member, and all of them share one audible instant through
479 the provider's plan registry, so every room renders in sync.
480 """
481 streams = [_make_stream(), _make_stream(), _make_stream()]
482 members = _make_playing_group(*streams)
483 leader, member_1, member_2 = members
484 provider = leader.provider
485 render = _make_render()
486 for member in members:
487 member.provider = provider
488 member.state.active_group = "syncgroup_1"
489 if member is not leader:
490 member.synced_to = leader.player_id
491 member.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
492 # the second member reports a far larger span: the shared instant must
493 # clear it for every sibling arm
494 streams[1].latency_lead_ms = 2000
495 announcement = _make_announcement()
496
497 await asyncio.gather(
498 announce.play_announcement(leader, announcement, None),
499 announce.play_announcement(member_1, announcement, None),
500 announce.play_announcement(member_2, announcement, None),
501 )
502
503 instants = set()
504 for stream in streams:
505 stream.announce.assert_awaited_once()
506 stream.wait_announce_done.assert_awaited_once()
507 instants.add(stream.announce.await_args.args[1])
508 assert len(instants) == 1
509 # the shared instant cleared the largest member span (2s + margin)
510 assert next(iter(instants)) >= int(time.time() * 1000) + 2000
511
512
513@pytest.mark.asyncio
514async def test_group_entity_session_leader_announces_alone() -> None:
515 """
516 A group entity's session leader addressed individually announces alone.
517
518 The entity itself is the whole-group handle, so leading the underlying
519 session does not widen an individual announcement.
520 """
521 streams = [_make_stream(), _make_stream()]
522 members = _make_playing_group(*streams)
523 leader = members[0]
524 leader.state.active_group = "syncgroup_1"
525 members[1].synced_to = leader.player_id
526
527 await announce.play_announcement(leader, _make_announcement(), None)
528
529 streams[0].announce.assert_awaited_once()
530 streams[1].announce.assert_not_awaited()
531
532
533@pytest.mark.asyncio
534async def test_protocol_child_reads_group_ownership_from_its_parent() -> None:
535 """
536 A protocol child leading a syncgroup's session still announces alone.
537
538 Protocol players never carry active_group themselves - the model keeps the
539 group state on the device player they render for - so the whole-group
540 handle is found through the protocol parent (the regression heard as an
541 individual announcement playing on the whole syncgroup).
542 """
543 streams = [_make_stream(), _make_stream()]
544 members = _make_playing_group(*streams)
545 leader = members[0]
546 leader.protocol_parent_id = "milo_parent"
547 parent = MagicMock()
548 parent.state.active_group = "syncgroup_1"
549 leader.mass.players.get_player = MagicMock(return_value=parent)
550 members[1].synced_to = leader.player_id
551
552 await announce.play_announcement(leader, _make_announcement(), None)
553
554 streams[0].announce.assert_awaited_once()
555 streams[1].announce.assert_not_awaited()
556
557
558@pytest.mark.asyncio
559async def test_member_announced_directly_arms_itself() -> None:
560 """A direct announcement to one synced member mixes on just that member."""
561 stream = _make_stream()
562 members = _make_playing_group(stream)
563 member = members[0]
564 member.synced_to = "some_leader"
565
566 await announce.play_announcement(member, _make_announcement(), None)
567
568 stream.announce.assert_awaited_once()
569
570
571@pytest.mark.asyncio
572async def test_synced_member_without_live_playback_is_refused() -> None:
573 """
574 A parked group member must not announce by tearing down the shared session.
575
576 Its stream belongs to the leader's parked session; stopping that for a
577 single-member announcement would silence every room in the group.
578 """
579 parked_stream = _make_stream()
580 members = _make_playing_group(parked_stream)
581 member = members[0]
582 member.synced_to = "some_leader"
583 member.playback_state = PlaybackState.PAUSED
584 parked_session = parked_stream.session
585 parked_session.stop = AsyncMock()
586
587 with pytest.raises(PlayerCommandFailed, match="without live playback"):
588 await announce.play_announcement(member, _make_announcement(), None)
589
590 parked_session.stop.assert_not_awaited()
591
592
593@pytest.mark.asyncio
594async def test_return_holds_until_the_audible_end() -> None:
595 """
596 The call returns only once the clip is audibly over, not when it is mixed.
597
598 announce_done reports MIX completion at the delivery head - ahead of
599 audibility - and the caller re-mutes muted players the moment this returns.
600 """
601 now_unix_ms = int(time.time() * 1000)
602 stream = _make_stream(ack=(now_unix_ms + 250, 100))
603 members = _make_playing_group(stream)
604
605 with patch.object(announce, "AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS", 100):
606 started = time.monotonic()
607 await announce.play_announcement(members[0], _make_announcement(), None)
608 elapsed = time.monotonic() - started
609
610 # audible end = acked instant + clip duration (0.35s out) + the 0.1s pad
611 assert elapsed >= 0.4
612
613
614@pytest.mark.asyncio
615async def test_bridged_player_with_live_stream_arms_itself() -> None:
616 """A Sendspin-bridged player mixes the clip into the bridge-owned stream."""
617 stream = _make_stream()
618 stream.session = None
619 player = _make_player("bridged", stream=stream)
620 bridge = MagicMock(owns_airplay_stream=True)
621 player.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
622
623 await announce.play_announcement(player, _make_announcement(), None)
624
625 stream.announce.assert_awaited_once()
626 stream.wait_announce_done.assert_awaited_once()
627
628
629@pytest.mark.asyncio
630async def test_bridge_configured_player_mixes_over_its_own_session() -> None:
631 """
632 A bridge that is merely configured never blocks the live mix.
633
634 The regression this pins: a player with a Sendspin bridge set up but
635 playing its own (session-backed) AirPlay stream mixes the clip like any
636 unbridged player - the idle bridge is a bystander.
637 """
638 (member,) = _make_playing_group(_make_stream())
639 bridge = MagicMock(owns_airplay_stream=False)
640 member.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
641
642 await announce.play_announcement(member, _make_announcement(), None)
643
644 member.stream.announce.assert_awaited_once()
645 member.stream.wait_announce_done.assert_awaited_once()
646
647
648@pytest.mark.asyncio
649async def test_bridge_streaming_player_never_runs_a_session_fallback() -> None:
650 """A player whose bridge owns the live stream fails instead of seizing the device."""
651 player = _make_player("bridged")
652 player.playback_state = PlaybackState.IDLE
653 bridge = MagicMock(owns_airplay_stream=True)
654 player.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
655
656 with (
657 patch.object(announce, "AirPlayStreamSession") as session_cls,
658 pytest.raises(PlayerCommandFailed, match="Sendspin bridge"),
659 ):
660 await announce.play_announcement(player, _make_announcement(), None)
661
662 session_cls.assert_not_called()
663
664
665@pytest.mark.asyncio
666async def test_announcement_without_data_is_refused() -> None:
667 """An announcement without its announce data cannot be rendered."""
668 player = _make_player("solo")
669
670 with pytest.raises(PlayerCommandFailed, match="carries no announcement data"):
671 await announce.play_announcement(player, MagicMock(custom_data=None), None)
672
673
674@pytest.mark.asyncio
675async def test_announcement_without_audio_plays_nothing(
676 caplog: pytest.LogCaptureFixture,
677) -> None:
678 """A render that produced no audio is skipped, releasing the render regardless."""
679 stream = _make_stream()
680 members = _make_playing_group(stream)
681 player = members[0]
682 render = _make_render(duration=0.0)
683 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
684
685 with caplog.at_level(logging.WARNING):
686 await announce.play_announcement(player, _make_announcement(), None)
687
688 stream.announce.assert_not_awaited()
689 assert "produced no audio" in caplog.text
690 player.mass.streams.announcement_renderer.release.assert_awaited_once()
691