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