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