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