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