/
/
1"""
2Tests for starting and releasing a live external source on a player.
3
4Selecting a source starts it on the player and leaves the player's queue alone;
5selecting anything else, or deselecting, gives the source back and tells the
6plugin so an upstream session stops pointing at Music Assistant.
7"""
8
9import asyncio
10from contextlib import suppress
11from typing import Any
12from unittest.mock import AsyncMock, MagicMock
13
14import pytest
15from music_assistant_models.enums import MediaType, PlaybackState, ProviderFeature
16from music_assistant_models.errors import MediaNotFoundError, PlayerCommandFailed
17from music_assistant_models.media_items import AudioSource, Track
18from music_assistant_models.media_items.provider_mapping import ProviderMapping
19from music_assistant_models.unique_list import UniqueList
20
21from music_assistant.controllers.players import PlayerController
22from music_assistant.controllers.players.constants import PlayerLockPurpose
23from music_assistant.models.player import Player, PlayerMedia
24from music_assistant.models.plugin import PluginProvider
25
26PLAYER_ID = "player_1"
27PROVIDER_INSTANCE = "spotify_connect--abc"
28SOURCE_URI = "spotify_connect--abc://audio_source/main"
29
30
31def _source(item_id: str = "main") -> AudioSource:
32 return AudioSource(
33 item_id=item_id,
34 provider=PROVIDER_INSTANCE,
35 name="Spotify Connect",
36 provider_mappings={
37 ProviderMapping(
38 item_id=item_id,
39 provider_domain="spotify_connect",
40 provider_instance=PROVIDER_INSTANCE,
41 )
42 },
43 )
44
45
46def _controller(resolved: Any = None) -> tuple[Any, MagicMock, MagicMock]:
47 """Build a controller whose music lookup returns ``resolved`` for any uri."""
48 mass = MagicMock()
49 mass.config.get_raw_core_config_value.return_value = "INFO"
50 controller = PlayerController(mass)
51 provider = MagicMock(spec=PluginProvider)
52 provider.instance_id = PROVIDER_INSTANCE
53 provider.supported_features = {ProviderFeature.AUDIO_SOURCE}
54 provider.on_source_released = AsyncMock()
55 mass.get_provider.return_value = provider
56 if isinstance(resolved, Exception):
57 mass.music.get_item_by_uri = AsyncMock(side_effect=resolved)
58 else:
59 mass.music.get_item_by_uri = AsyncMock(return_value=resolved)
60 player = MagicMock()
61 player.player_id = PLAYER_ID
62 player.display_name = "Player 1"
63 player.available = True
64 player.state.active_source = None
65 player.state.active_group = None
66 player.state.synced_to = None
67 player.protocol_parent_id = None
68 controller._players[PLAYER_ID] = player
69 controller.get_player = MagicMock(return_value=player) # type: ignore[method-assign]
70 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
71 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
72 controller.trigger_player_update = MagicMock() # type: ignore[method-assign]
73 return controller, provider, player
74
75
76async def test_selecting_a_source_starts_it_and_names_it_on_the_player() -> None:
77 """The source becomes a session on the player, and playback is started for it."""
78 source = _source()
79 controller, _provider, _player = _controller(source)
80
81 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
82
83 session = controller.get_audio_source_session(PLAYER_ID)
84 assert session is not None
85 assert session.source is source
86 assert session.provider_instance_id == PROVIDER_INSTANCE
87 controller._handle_play_media.assert_awaited_once()
88 media = controller._handle_play_media.await_args.args[1]
89 assert media.media_type is MediaType.AUDIO_SOURCE
90 # the session's owner, which its stream url is keyed on
91 assert media.source_id == PLAYER_ID
92 assert media.queue_session_id == session.playback_session_id
93 # no queue item: this is what tells the stream layer it is not queue content
94 assert media.queue_item_id is None
95
96
97async def test_selecting_a_source_on_an_idle_player_is_not_held_up_by_a_stop() -> None:
98 """An idle player has no teardown to settle, so the selection starts straight away."""
99 controller, _provider, player = _controller(_source())
100 player.state.active_source = "some_other_source"
101 player.state.playback_state = PlaybackState.IDLE
102
103 # waiting on a state change that a no-op stop never reports would burn the
104 # full 5s timeout; this bound sits well below that and well above a prompt return
105 async with asyncio.timeout(2):
106 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
107
108 controller._handle_cmd_stop.assert_awaited_once_with(PLAYER_ID)
109 controller._handle_play_media.assert_awaited_once()
110
111
112async def test_selecting_a_source_waits_for_a_playing_player_to_stop() -> None:
113 """A player that was playing is settled before the new source is started on it."""
114 controller, _provider, player = _controller(_source())
115 player.state.active_source = "some_other_source"
116 player.state.playback_state = PlaybackState.PLAYING
117 state_at_start: list[PlaybackState] = []
118 settle_task: asyncio.Task[None] | None = None
119
120 async def _stop(_player_id: str) -> None:
121 # a real device reports back a moment after the stop command returns
122 async def _settle() -> None:
123 await asyncio.sleep(0.1)
124 player.state.playback_state = PlaybackState.IDLE
125 controller._dispatch_state_update_subscribers(
126 player, {"playback_state": (PlaybackState.PLAYING, PlaybackState.IDLE)}
127 )
128
129 nonlocal settle_task
130 settle_task = asyncio.create_task(_settle())
131
132 async def _play_media(*_args: Any, **_kwargs: Any) -> None:
133 state_at_start.append(player.state.playback_state)
134
135 controller._handle_cmd_stop = AsyncMock(side_effect=_stop)
136 controller._handle_play_media = AsyncMock(side_effect=_play_media)
137
138 async with asyncio.timeout(2):
139 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
140
141 controller._handle_cmd_stop.assert_awaited_once_with(PLAYER_ID)
142 assert state_at_start == [PlaybackState.IDLE]
143
144
145async def test_selecting_a_source_does_not_touch_the_queue() -> None:
146 """The queue is not cleared, replaced or loaded â it just stops being the active source."""
147 controller, _provider, _player = _controller(_source())
148
149 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
150
151 controller.mass.player_queues.load.assert_not_called()
152 controller.mass.player_queues.clear.assert_not_called()
153
154
155async def test_selecting_another_source_releases_the_first() -> None:
156 """A player plays one source at a time, and the one it leaves is handed back."""
157 controller, provider, _player = _controller(_source())
158 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
159 controller.mass.music.get_item_by_uri = AsyncMock(side_effect=MediaNotFoundError("nope"))
160 controller.mass.player_queues.get = MagicMock(return_value=MagicMock())
161
162 await controller._handle_select_source(PLAYER_ID, PLAYER_ID)
163
164 assert controller.get_audio_source_session(PLAYER_ID) is None
165 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
166
167
168async def test_deselecting_releases_the_source_and_stops_the_player() -> None:
169 """The source owner can give its source back and stop playback."""
170 controller, provider, _player = _controller(_source())
171 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
172 session = controller.get_audio_source_session(PLAYER_ID)
173 assert session is not None
174
175 await controller.deselect_source(
176 PLAYER_ID,
177 provider_instance_id=PROVIDER_INSTANCE,
178 source_id="main",
179 playback_session_id=session.playback_session_id,
180 )
181
182 assert controller.get_audio_source_session(PLAYER_ID) is None
183 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
184 controller._handle_cmd_stop.assert_awaited_once()
185
186
187async def test_deselecting_from_another_provider_leaves_the_source_playing() -> None:
188 """A provider cannot release or stop a source session it does not own."""
189 controller, provider, _player = _controller(_source())
190 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
191 session = controller.get_audio_source_session(PLAYER_ID)
192 assert session is not None
193
194 await controller.deselect_source(
195 PLAYER_ID,
196 provider_instance_id="airplay_receiver--xyz",
197 source_id="main",
198 playback_session_id=session.playback_session_id,
199 )
200
201 assert controller.get_audio_source_session(PLAYER_ID) is session
202 provider.on_source_released.assert_not_awaited()
203 controller._handle_cmd_stop.assert_not_awaited()
204
205
206async def test_deselecting_without_an_owned_session_does_not_stop_the_player() -> None:
207 """A provider cannot stop a player when it owns no source session."""
208 controller, provider, _player = _controller(None)
209
210 await controller.deselect_source(
211 PLAYER_ID,
212 provider_instance_id=PROVIDER_INSTANCE,
213 source_id="main",
214 playback_session_id="stale-session",
215 )
216
217 provider.on_source_released.assert_not_awaited()
218 controller._handle_cmd_stop.assert_not_awaited()
219
220
221async def test_a_provider_release_without_a_playback_session_is_rejected() -> None:
222 """Provider cleanup without a captured playback generation is not authoritative."""
223 controller, provider, _player = _controller(_source())
224 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
225 session = controller.get_audio_source_session(PLAYER_ID)
226
227 await controller.deselect_source(
228 PLAYER_ID,
229 provider_instance_id=PROVIDER_INSTANCE,
230 source_id="main",
231 )
232
233 assert controller.get_audio_source_session(PLAYER_ID) is session
234 provider.on_source_released.assert_not_awaited()
235 controller._handle_cmd_stop.assert_not_awaited()
236
237
238async def test_an_unexpected_stop_failure_still_releases_the_source() -> None:
239 """Source cleanup completes before an unexpected stop error propagates."""
240 controller, provider, _player = _controller(_source())
241 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
242 session = controller.get_audio_source_session(PLAYER_ID)
243 assert session is not None
244 controller._handle_cmd_stop.side_effect = OSError("transport failed")
245
246 with pytest.raises(OSError, match="transport failed"):
247 await controller.deselect_source(
248 PLAYER_ID,
249 provider_instance_id=PROVIDER_INSTANCE,
250 source_id="main",
251 playback_session_id=session.playback_session_id,
252 )
253
254 assert controller.get_audio_source_session(PLAYER_ID) is None
255 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
256
257
258async def test_a_replacement_source_during_release_is_not_stopped() -> None:
259 """A source taking over during release remains active and playing."""
260 controller, provider, _player = _controller(_source())
261 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
262 original_session = controller.get_audio_source_session(PLAYER_ID)
263 assert original_session is not None
264 replacement_instance = "airplay_receiver--xyz"
265 replacement = AudioSource(
266 item_id="receiver",
267 provider=replacement_instance,
268 name="AirPlay",
269 provider_mappings={
270 ProviderMapping(
271 item_id="receiver",
272 provider_domain="airplay_receiver",
273 provider_instance=replacement_instance,
274 )
275 },
276 )
277
278 async def start_replacement(_source_id: str, _player_id: str) -> None:
279 controller._start_audio_source_session(PLAYER_ID, replacement, replacement_instance)
280
281 provider.on_source_released.side_effect = start_replacement
282
283 await controller.deselect_source(
284 PLAYER_ID,
285 provider_instance_id=PROVIDER_INSTANCE,
286 source_id="main",
287 playback_session_id=original_session.playback_session_id,
288 )
289
290 session = controller.get_audio_source_session(PLAYER_ID)
291 assert session is not None
292 assert session.source is replacement
293 controller._handle_cmd_stop.assert_awaited_once()
294
295
296async def test_deselecting_another_source_from_the_same_provider_is_rejected() -> None:
297 """A provider cannot release a different source session from the one that ended."""
298 controller, provider, _player = _controller(_source())
299 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
300 original_session = controller.get_audio_source_session(PLAYER_ID)
301 assert original_session is not None
302 replacement = _source("other")
303 session = controller._start_audio_source_session(PLAYER_ID, replacement, PROVIDER_INSTANCE)
304
305 await controller.deselect_source(
306 PLAYER_ID,
307 provider_instance_id=PROVIDER_INSTANCE,
308 source_id="main",
309 playback_session_id=original_session.playback_session_id,
310 )
311
312 assert controller.get_audio_source_session(PLAYER_ID) is session
313 provider.on_source_released.assert_not_awaited()
314 controller._handle_cmd_stop.assert_not_awaited()
315
316
317async def test_deselecting_finishes_before_new_playback_starts() -> None:
318 """Release and stop complete before any player playback entry point starts."""
319 controller, provider, player = _controller(_source())
320 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
321 session = controller.get_audio_source_session(PLAYER_ID)
322 assert session is not None
323 release_started = asyncio.Event()
324 finish_release = asyncio.Event()
325 events: list[str] = []
326
327 async def release_source(_source_id: str, _player_id: str) -> None:
328 release_started.set()
329 await finish_release.wait()
330
331 async def stop_player(_player_id: str) -> None:
332 events.append("stop")
333
334 async def play_media(_player_id: str, _media: PlayerMedia) -> None:
335 events.append("play")
336
337 async def select_source(_player_id: str, _source_id: str | None) -> None:
338 events.append("select")
339
340 async def play(_player_id: str) -> None:
341 events.append("play_command")
342
343 async def resume(
344 _player_id: str,
345 _source_id: str | None,
346 _media: PlayerMedia | None,
347 ) -> None:
348 events.append("resume")
349
350 provider.on_source_released.side_effect = release_source
351 controller._handle_cmd_stop.side_effect = stop_player
352 controller._handle_play_media = AsyncMock(side_effect=play_media)
353 controller._handle_select_source = AsyncMock(side_effect=select_source)
354 controller._handle_cmd_play = AsyncMock(side_effect=play)
355 controller._handle_cmd_resume = AsyncMock(side_effect=resume)
356 player.state.playback_state = PlaybackState.IDLE
357 controller.mass.player_queues.get.return_value = None
358
359 release_task = asyncio.create_task(
360 controller.deselect_source(
361 PLAYER_ID,
362 provider_instance_id=PROVIDER_INSTANCE,
363 source_id="main",
364 playback_session_id=session.playback_session_id,
365 )
366 )
367 await release_started.wait()
368 assert events == ["stop"]
369 play_task = asyncio.create_task(
370 controller.play_media(
371 PLAYER_ID,
372 PlayerMedia(uri="library://track/1", media_type=MediaType.TRACK),
373 )
374 )
375 select_task = asyncio.create_task(controller.select_source(PLAYER_ID, PLAYER_ID))
376 play_command_task = asyncio.create_task(controller.cmd_play(PLAYER_ID))
377 resume_task = asyncio.create_task(controller.cmd_resume(PLAYER_ID))
378 await asyncio.sleep(0)
379 playback_waited = events == ["stop"]
380
381 finish_release.set()
382 await asyncio.gather(
383 release_task,
384 play_task,
385 select_task,
386 play_command_task,
387 resume_task,
388 )
389
390 assert playback_waited
391 assert events[0] == "stop"
392 assert set(events[1:]) == {"play", "play_command", "resume", "select"}
393
394
395async def test_playback_starting_during_release_callback_is_not_stopped() -> None:
396 """Playback bypassing the lock during plugin release sees no later stale stop."""
397 controller, provider, _player = _controller(_source())
398 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
399 session = controller.get_audio_source_session(PLAYER_ID)
400 assert session is not None
401 release_started = asyncio.Event()
402 finish_release = asyncio.Event()
403 events: list[str] = []
404
405 async def release_source(_source_id: str, _player_id: str) -> None:
406 release_started.set()
407 await finish_release.wait()
408
409 async def stop_player(_player_id: str) -> None:
410 events.append("stop")
411
412 async def play_media(_player_id: str, _media: PlayerMedia) -> None:
413 events.append("play")
414
415 provider.on_source_released.side_effect = release_source
416 controller._handle_cmd_stop.side_effect = stop_player
417 controller._handle_play_media = AsyncMock(side_effect=play_media)
418
419 release_task = asyncio.create_task(
420 controller.deselect_source(
421 PLAYER_ID,
422 provider_instance_id=PROVIDER_INSTANCE,
423 source_id="main",
424 playback_session_id=session.playback_session_id,
425 )
426 )
427 await release_started.wait()
428 await controller._handle_play_media(
429 PLAYER_ID,
430 PlayerMedia(uri="library://track/1", media_type=MediaType.TRACK),
431 )
432 finish_release.set()
433 await release_task
434
435 assert events == ["stop", "play"]
436
437
438async def test_cmd_play_checks_playing_state_after_cleanup_finishes() -> None:
439 """A play command waiting on cleanup rechecks state after the old stop."""
440 controller, _provider, player = _controller(_source())
441 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
442 session = controller.get_audio_source_session(PLAYER_ID)
443 assert session is not None
444 player.state.playback_state = PlaybackState.PLAYING
445 stop_started = asyncio.Event()
446 finish_stop = asyncio.Event()
447
448 async def stop_player(_player_id: str) -> None:
449 stop_started.set()
450 await finish_stop.wait()
451 player.state.playback_state = PlaybackState.IDLE
452
453 controller._handle_cmd_stop.side_effect = stop_player
454 controller._handle_cmd_play = AsyncMock()
455 controller.mass.player_queues.get.return_value = None
456
457 release_task = asyncio.create_task(
458 controller.deselect_source(
459 PLAYER_ID,
460 provider_instance_id=PROVIDER_INSTANCE,
461 source_id="main",
462 playback_session_id=session.playback_session_id,
463 )
464 )
465 await stop_started.wait()
466 play_task = asyncio.create_task(controller.cmd_play(PLAYER_ID))
467 await asyncio.sleep(0)
468 controller._handle_cmd_play.assert_not_awaited()
469
470 finish_stop.set()
471 await asyncio.gather(release_task, play_task)
472
473 controller._handle_cmd_play.assert_awaited_once_with(PLAYER_ID)
474
475
476async def test_cmd_stop_locks_the_redirected_playback_owner() -> None:
477 """A member stop locks its group rather than taking the locks in reverse order."""
478 controller, _provider, player = _controller(None)
479 group_id = "group_1"
480 group = MagicMock()
481 group.player_id = group_id
482 group.available = True
483 group.protocol_parent_id = None
484 group.state.active_group = None
485 group.state.synced_to = None
486 controller._players[group_id] = group
487 players = {PLAYER_ID: player, group_id: group}
488 controller.get_player.side_effect = lambda player_id, *_args, **_kwargs: players.get(player_id)
489 player.state.active_group = group_id
490 controller.mass.player_queues.get.return_value = None
491
492 await controller.cmd_stop(PLAYER_ID)
493
494 controller._handle_cmd_stop.assert_awaited_once_with(group_id)
495 assert f"{PlayerLockPurpose.PLAYBACK.value}_{group_id}" in controller._player_command_locks
496 assert f"{PlayerLockPurpose.PLAYBACK.value}_{PLAYER_ID}" not in controller._player_command_locks
497
498
499async def test_releasing_a_player_with_nothing_playing_is_a_no_op() -> None:
500 """A release for a player holding no source tells no plugin anything."""
501 controller, provider, _player = _controller(None)
502
503 await controller._release_audio_source(PLAYER_ID)
504
505 provider.on_source_released.assert_not_awaited()
506
507
508async def test_a_plugin_that_raises_does_not_block_the_player_moving_on() -> None:
509 """The session is dropped even when the owning plugin fails to let go."""
510 controller, provider, _player = _controller(_source())
511 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
512 provider.on_source_released.side_effect = OSError("daemon gone")
513
514 await controller._release_audio_source(PLAYER_ID)
515
516 assert controller.get_audio_source_session(PLAYER_ID) is None
517
518
519async def test_a_non_source_uri_is_not_treated_as_one() -> None:
520 """A uri resolving to ordinary media is left to the rest of the source handling."""
521 controller, _provider, _player = _controller(
522 Track(
523 item_id="t1",
524 provider="test",
525 name="A track",
526 artists=UniqueList(),
527 provider_mappings={
528 ProviderMapping(item_id="t1", provider_domain="test", provider_instance="test")
529 },
530 )
531 )
532
533 assert await controller._resolve_audio_source_uri("library://track/t1") is None
534
535
536async def test_a_source_string_that_is_not_a_uri_is_not_resolved() -> None:
537 """A player-native source id is not a uri, so the music lookup is never attempted."""
538 controller, _provider, _player = _controller(_source())
539
540 assert await controller._resolve_audio_source_uri("line-in") is None
541 controller.mass.music.get_item_by_uri.assert_not_awaited()
542
543
544async def test_an_unresolvable_uri_is_not_treated_as_a_source() -> None:
545 """A uri the music controller cannot resolve falls through rather than raising."""
546 controller, _provider, _player = _controller(MediaNotFoundError("gone"))
547
548 assert await controller._resolve_audio_source_uri(SOURCE_URI) is None
549
550
551async def test_a_source_whose_provider_dropped_the_feature_is_not_started() -> None:
552 """A provider that no longer exposes audio sources cannot be selected from."""
553 controller, provider, _player = _controller(_source())
554 provider.supported_features = set()
555
556 assert await controller._resolve_audio_source_uri(SOURCE_URI) is None
557
558
559async def test_unregistering_a_player_releases_its_source() -> None:
560 """
561 A player going away hands its source back.
562
563 Otherwise the session outlives the player, the plugin is never told, and an
564 upstream session stays pointed at a player that no longer exists.
565 """
566 controller, provider, _player = _controller(_source())
567 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
568 assert controller.get_audio_source_session(PLAYER_ID) is not None
569
570 await controller.unregister(PLAYER_ID)
571
572 assert controller.get_audio_source_session(PLAYER_ID) is None
573 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
574
575
576async def test_refreshing_a_rebuilt_source_reaches_the_session() -> None:
577 """A plugin that rebuilds its source has the new capability flags published."""
578 controller, _provider, _player = _controller(_source())
579 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
580 session = controller.get_audio_source_session(PLAYER_ID)
581 assert session is not None
582 assert session.source.can_seek is False
583
584 rebuilt = AudioSource(
585 item_id="main",
586 provider=PROVIDER_INSTANCE,
587 name="Spotify Connect",
588 provider_mappings={
589 ProviderMapping(
590 item_id="main",
591 provider_domain="spotify_connect",
592 provider_instance=PROVIDER_INSTANCE,
593 )
594 },
595 can_seek=True,
596 )
597 controller.refresh_source(PLAYER_ID, rebuilt)
598
599 assert session.source is rebuilt
600 assert session.source.can_seek is True
601
602
603async def test_refreshing_with_another_providers_source_is_rejected() -> None:
604 """A provider cannot publish its object onto a session it does not own."""
605 controller, _provider, _player = _controller(_source())
606 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
607 session = controller.get_audio_source_session(PLAYER_ID)
608 assert session is not None
609 original = session.source
610
611 controller.refresh_source(
612 PLAYER_ID,
613 AudioSource(
614 item_id="main",
615 provider="airplay_receiver--xyz",
616 name="AirPlay",
617 provider_mappings=set(),
618 ),
619 )
620
621 assert session.source is original
622
623
624async def test_swapping_one_source_for_another_hands_the_first_back() -> None:
625 """
626 A player switching between two live sources tells the first one's plugin.
627
628 Otherwise the displaced plugin keeps an upstream session pointed at a player it
629 no longer has, and the session it holds is simply overwritten in silence.
630 """
631 controller, first_provider, _player = _controller(_source())
632 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
633
634 other_instance = "airplay_receiver--xyz"
635 other_provider = MagicMock(spec=PluginProvider)
636 other_provider.instance_id = other_instance
637 other_provider.supported_features = {ProviderFeature.AUDIO_SOURCE}
638 other_provider.on_source_released = AsyncMock()
639 providers = {PROVIDER_INSTANCE: first_provider, other_instance: other_provider}
640 controller.mass.get_provider = MagicMock(side_effect=lambda key: providers.get(key))
641 other_source = AudioSource(
642 item_id="receiver",
643 provider=other_instance,
644 name="AirPlay",
645 provider_mappings={
646 ProviderMapping(
647 item_id="receiver",
648 provider_domain="airplay_receiver",
649 provider_instance=other_instance,
650 )
651 },
652 )
653 controller.mass.music.get_item_by_uri = AsyncMock(return_value=other_source)
654
655 await controller._handle_select_source(PLAYER_ID, f"{other_instance}://audio_source/receiver")
656
657 first_provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
658 other_provider.on_source_released.assert_not_awaited()
659 session = controller.get_audio_source_session(PLAYER_ID)
660 assert session is not None
661 assert session.provider_instance_id == other_instance
662
663
664async def test_reselecting_the_same_source_does_not_hand_it_back() -> None:
665 """A player reconnecting to the source it already has keeps its session."""
666 controller, provider, _player = _controller(_source())
667 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
668 first = controller.get_audio_source_session(PLAYER_ID)
669 assert first is not None
670 first_playback_session_id = first.playback_session_id
671
672 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
673
674 provider.on_source_released.assert_not_awaited()
675 assert controller.get_audio_source_session(PLAYER_ID) is first
676 assert first.playback_session_id != first_playback_session_id
677
678
679async def test_a_stale_release_cannot_end_a_reselected_source() -> None:
680 """A delayed cleanup cannot release a new selection of the same source."""
681 controller, provider, _player = _controller(_source())
682 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
683 session = controller.get_audio_source_session(PLAYER_ID)
684 assert session is not None
685 stale_playback_session_id = session.playback_session_id
686
687 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
688 await controller.deselect_source(
689 PLAYER_ID,
690 provider_instance_id=PROVIDER_INSTANCE,
691 source_id="main",
692 playback_session_id=stale_playback_session_id,
693 )
694
695 assert controller.get_audio_source_session(PLAYER_ID) is session
696 provider.on_source_released.assert_not_awaited()
697 controller._handle_cmd_stop.assert_not_awaited()
698
699
700async def test_a_source_that_fails_to_start_is_not_left_on_the_player() -> None:
701 """
702 A failed play command rolls the session back.
703
704 A session left behind would have the player publish a source that never started,
705 and its queue stays inactive with nothing playing it â unreachable from play.
706 """
707 controller, provider, _player = _controller(_source())
708 controller._handle_play_media = AsyncMock(side_effect=PlayerCommandFailed("no route"))
709
710 with pytest.raises(PlayerCommandFailed):
711 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
712
713 assert controller.get_audio_source_session(PLAYER_ID) is None
714 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
715
716
717async def test_an_announcement_does_not_take_the_source_off_the_player() -> None:
718 """
719 An announcement interrupts the player without ending the source session.
720
721 The player is handed straight back afterwards, and a released source cannot be
722 re-selected: its plugin has let go of the upstream session by then.
723 """
724 controller, provider, _player = _controller(_source())
725 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
726 session = controller.get_audio_source_session(PLAYER_ID)
727 controller._handle_play_media = PlayerController._handle_play_media.__get__(controller)
728 controller.get_player = MagicMock(return_value=_player)
729 _player.play_media = AsyncMock()
730
731 with suppress(Exception):
732 await controller._handle_play_media(
733 PLAYER_ID,
734 PlayerMedia(uri="http://x/announce.mp3", media_type=MediaType.ANNOUNCEMENT),
735 )
736
737 assert controller.get_audio_source_session(PLAYER_ID) is session
738 provider.on_source_released.assert_not_awaited()
739
740
741async def test_ordinary_media_does_take_the_source_off_the_player() -> None:
742 """Anything that is not transient ends the session, so the source is handed back."""
743 controller, provider, _player = _controller(_source())
744 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
745 controller._handle_play_media = PlayerController._handle_play_media.__get__(controller)
746 controller.get_player = MagicMock(return_value=_player)
747 _player.play_media = AsyncMock()
748
749 with suppress(Exception):
750 await controller._handle_play_media(
751 PLAYER_ID,
752 PlayerMedia(uri="library://track/1", media_type=MediaType.TRACK),
753 )
754
755 assert controller.get_audio_source_session(PLAYER_ID) is None
756 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
757
758
759async def test_a_plugin_unloading_releases_the_sources_it_owns() -> None:
760 """
761 A plugin going away takes its sources off the players playing them.
762
763 A session outliving its provider leaves the player naming a source that can no
764 longer be streamed, and the queue behind it stays inactive.
765 """
766 controller, provider, _player = _controller(_source())
767 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
768 assert controller.get_audio_source_session(PLAYER_ID) is not None
769
770 await controller.release_provider_sources(PROVIDER_INSTANCE)
771
772 assert controller.get_audio_source_session(PLAYER_ID) is None
773 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
774
775
776async def test_another_plugin_unloading_leaves_the_session_alone() -> None:
777 """Only the sources of the plugin that is going away are given back."""
778 controller, provider, _player = _controller(_source())
779 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
780 session = controller.get_audio_source_session(PLAYER_ID)
781
782 await controller.release_provider_sources("some_other_provider--1")
783
784 assert controller.get_audio_source_session(PLAYER_ID) is session
785 provider.on_source_released.assert_not_awaited()
786
787
788async def test_the_reported_media_can_be_handed_back_to_the_player() -> None:
789 """
790 What the player reports it is playing carries the session token.
791
792 The announcement restore hands this object straight back to the player, and the
793 stream url cannot be resolved without the token the session is keyed on.
794 """
795 controller, _provider, _player = _controller(_source())
796 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
797 session = controller.get_audio_source_session(PLAYER_ID)
798 assert session is not None
799
800 media = controller._handle_play_media.await_args.args[1]
801 # the reported position is taken from the player's final playback state
802 _player._Player__final_playback_state = (PlaybackState.PLAYING, None, None)
803 reported = Player._Player__audio_source_media(_player, session) # type: ignore[attr-defined]
804
805 assert reported.queue_session_id == session.playback_session_id
806 assert reported.source_id == media.source_id
807