/
/
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
9from contextlib import suppress
10from typing import Any
11from unittest.mock import AsyncMock, MagicMock
12
13import pytest
14from music_assistant_models.enums import MediaType, ProviderFeature
15from music_assistant_models.errors import MediaNotFoundError, PlayerCommandFailed
16from music_assistant_models.media_items import AudioSource, Track
17from music_assistant_models.media_items.provider_mapping import ProviderMapping
18from music_assistant_models.unique_list import UniqueList
19
20from music_assistant.controllers.players import PlayerController
21from music_assistant.models.player import Player, PlayerMedia
22from music_assistant.models.plugin import PluginProvider
23
24PLAYER_ID = "player_1"
25PROVIDER_INSTANCE = "spotify_connect--abc"
26SOURCE_URI = "spotify_connect--abc://audio_source/main"
27
28
29def _source() -> AudioSource:
30 return AudioSource(
31 item_id="main",
32 provider=PROVIDER_INSTANCE,
33 name="Spotify Connect",
34 provider_mappings={
35 ProviderMapping(
36 item_id="main",
37 provider_domain="spotify_connect",
38 provider_instance=PROVIDER_INSTANCE,
39 )
40 },
41 )
42
43
44def _controller(resolved: Any = None) -> tuple[Any, MagicMock, MagicMock]:
45 """Build a controller whose music lookup returns ``resolved`` for any uri."""
46 mass = MagicMock()
47 mass.config.get_raw_core_config_value.return_value = "INFO"
48 controller = PlayerController(mass)
49 provider = MagicMock(spec=PluginProvider)
50 provider.instance_id = PROVIDER_INSTANCE
51 provider.supported_features = {ProviderFeature.AUDIO_SOURCE}
52 provider.on_source_released = AsyncMock()
53 mass.get_provider.return_value = provider
54 if isinstance(resolved, Exception):
55 mass.music.get_item_by_uri = AsyncMock(side_effect=resolved)
56 else:
57 mass.music.get_item_by_uri = AsyncMock(return_value=resolved)
58 player = MagicMock()
59 player.player_id = PLAYER_ID
60 player.display_name = "Player 1"
61 player.available = True
62 player.state.active_source = None
63 player.state.active_group = None
64 player.state.synced_to = None
65 player.protocol_parent_id = None
66 controller._players[PLAYER_ID] = player
67 controller.get_player = MagicMock(return_value=player) # type: ignore[method-assign]
68 controller._handle_play_media = AsyncMock() # type: ignore[method-assign]
69 controller._handle_cmd_stop = AsyncMock() # type: ignore[method-assign]
70 controller.trigger_player_update = MagicMock() # type: ignore[method-assign]
71 return controller, provider, player
72
73
74async def test_selecting_a_source_starts_it_and_names_it_on_the_player() -> None:
75 """The source becomes a session on the player, and playback is started for it."""
76 source = _source()
77 controller, _provider, _player = _controller(source)
78
79 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
80
81 session = controller.get_audio_source_session(PLAYER_ID)
82 assert session is not None
83 assert session.source is source
84 assert session.provider_instance_id == PROVIDER_INSTANCE
85 controller._handle_play_media.assert_awaited_once()
86 media = controller._handle_play_media.await_args.args[1]
87 assert media.media_type is MediaType.AUDIO_SOURCE
88 # the session's owner, which its stream url is keyed on
89 assert media.source_id == PLAYER_ID
90 assert media.queue_session_id == session.playback_session_id
91 # no queue item: this is what tells the stream layer it is not queue content
92 assert media.queue_item_id is None
93
94
95async def test_selecting_a_source_does_not_touch_the_queue() -> None:
96 """The queue is not cleared, replaced or loaded â it just stops being the active source."""
97 controller, _provider, _player = _controller(_source())
98
99 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
100
101 controller.mass.player_queues.load.assert_not_called()
102 controller.mass.player_queues.clear.assert_not_called()
103
104
105async def test_selecting_another_source_releases_the_first() -> None:
106 """A player plays one source at a time, and the one it leaves is handed back."""
107 controller, provider, _player = _controller(_source())
108 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
109 controller.mass.music.get_item_by_uri = AsyncMock(side_effect=MediaNotFoundError("nope"))
110 controller.mass.player_queues.get = MagicMock(return_value=MagicMock())
111
112 await controller._handle_select_source(PLAYER_ID, PLAYER_ID)
113
114 assert controller.get_audio_source_session(PLAYER_ID) is None
115 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
116
117
118async def test_deselecting_releases_the_source_and_stops_the_player() -> None:
119 """An explicit deselect gives the source back and stops playback."""
120 controller, provider, _player = _controller(_source())
121 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
122
123 await controller.deselect_source(PLAYER_ID)
124
125 assert controller.get_audio_source_session(PLAYER_ID) is None
126 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
127 controller._handle_cmd_stop.assert_awaited_once()
128
129
130async def test_releasing_a_player_with_nothing_playing_is_a_no_op() -> None:
131 """A release for a player holding no source tells no plugin anything."""
132 controller, provider, _player = _controller(None)
133
134 await controller._release_audio_source(PLAYER_ID)
135
136 provider.on_source_released.assert_not_awaited()
137
138
139async def test_a_plugin_that_raises_does_not_block_the_player_moving_on() -> None:
140 """The session is dropped even when the owning plugin fails to let go."""
141 controller, provider, _player = _controller(_source())
142 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
143 provider.on_source_released.side_effect = OSError("daemon gone")
144
145 await controller._release_audio_source(PLAYER_ID)
146
147 assert controller.get_audio_source_session(PLAYER_ID) is None
148
149
150async def test_a_non_source_uri_is_not_treated_as_one() -> None:
151 """A uri resolving to ordinary media is left to the rest of the source handling."""
152 controller, _provider, _player = _controller(
153 Track(
154 item_id="t1",
155 provider="test",
156 name="A track",
157 artists=UniqueList(),
158 provider_mappings={
159 ProviderMapping(item_id="t1", provider_domain="test", provider_instance="test")
160 },
161 )
162 )
163
164 assert await controller._resolve_audio_source_uri("library://track/t1") is None
165
166
167async def test_a_source_string_that_is_not_a_uri_is_not_resolved() -> None:
168 """A player-native source id is not a uri, so the music lookup is never attempted."""
169 controller, _provider, _player = _controller(_source())
170
171 assert await controller._resolve_audio_source_uri("line-in") is None
172 controller.mass.music.get_item_by_uri.assert_not_awaited()
173
174
175async def test_an_unresolvable_uri_is_not_treated_as_a_source() -> None:
176 """A uri the music controller cannot resolve falls through rather than raising."""
177 controller, _provider, _player = _controller(MediaNotFoundError("gone"))
178
179 assert await controller._resolve_audio_source_uri(SOURCE_URI) is None
180
181
182async def test_a_source_whose_provider_dropped_the_feature_is_not_started() -> None:
183 """A provider that no longer exposes audio sources cannot be selected from."""
184 controller, provider, _player = _controller(_source())
185 provider.supported_features = set()
186
187 assert await controller._resolve_audio_source_uri(SOURCE_URI) is None
188
189
190async def test_unregistering_a_player_releases_its_source() -> None:
191 """
192 A player going away hands its source back.
193
194 Otherwise the session outlives the player, the plugin is never told, and an
195 upstream session stays pointed at a player that no longer exists.
196 """
197 controller, provider, _player = _controller(_source())
198 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
199 assert controller.get_audio_source_session(PLAYER_ID) is not None
200
201 await controller.unregister(PLAYER_ID)
202
203 assert controller.get_audio_source_session(PLAYER_ID) is None
204 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
205
206
207async def test_refreshing_a_rebuilt_source_reaches_the_session() -> None:
208 """A plugin that rebuilds its source has the new capability flags published."""
209 controller, _provider, _player = _controller(_source())
210 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
211 session = controller.get_audio_source_session(PLAYER_ID)
212 assert session is not None
213 assert session.source.can_seek is False
214
215 rebuilt = AudioSource(
216 item_id="main",
217 provider=PROVIDER_INSTANCE,
218 name="Spotify Connect",
219 provider_mappings={
220 ProviderMapping(
221 item_id="main",
222 provider_domain="spotify_connect",
223 provider_instance=PROVIDER_INSTANCE,
224 )
225 },
226 can_seek=True,
227 )
228 controller.refresh_source(PLAYER_ID, rebuilt)
229
230 assert session.source is rebuilt
231 assert session.source.can_seek is True
232
233
234async def test_refreshing_with_another_providers_source_is_rejected() -> None:
235 """A provider cannot publish its object onto a session it does not own."""
236 controller, _provider, _player = _controller(_source())
237 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
238 session = controller.get_audio_source_session(PLAYER_ID)
239 assert session is not None
240 original = session.source
241
242 controller.refresh_source(
243 PLAYER_ID,
244 AudioSource(
245 item_id="main",
246 provider="airplay_receiver--xyz",
247 name="AirPlay",
248 provider_mappings=set(),
249 ),
250 )
251
252 assert session.source is original
253
254
255async def test_swapping_one_source_for_another_hands_the_first_back() -> None:
256 """
257 A player switching between two live sources tells the first one's plugin.
258
259 Otherwise the displaced plugin keeps an upstream session pointed at a player it
260 no longer has, and the session it holds is simply overwritten in silence.
261 """
262 controller, first_provider, _player = _controller(_source())
263 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
264
265 other_instance = "airplay_receiver--xyz"
266 other_provider = MagicMock(spec=PluginProvider)
267 other_provider.instance_id = other_instance
268 other_provider.supported_features = {ProviderFeature.AUDIO_SOURCE}
269 other_provider.on_source_released = AsyncMock()
270 providers = {PROVIDER_INSTANCE: first_provider, other_instance: other_provider}
271 controller.mass.get_provider = MagicMock(side_effect=lambda key: providers.get(key))
272 other_source = AudioSource(
273 item_id="receiver",
274 provider=other_instance,
275 name="AirPlay",
276 provider_mappings={
277 ProviderMapping(
278 item_id="receiver",
279 provider_domain="airplay_receiver",
280 provider_instance=other_instance,
281 )
282 },
283 )
284 controller.mass.music.get_item_by_uri = AsyncMock(return_value=other_source)
285
286 await controller._handle_select_source(PLAYER_ID, f"{other_instance}://audio_source/receiver")
287
288 first_provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
289 other_provider.on_source_released.assert_not_awaited()
290 session = controller.get_audio_source_session(PLAYER_ID)
291 assert session is not None
292 assert session.provider_instance_id == other_instance
293
294
295async def test_reselecting_the_same_source_does_not_hand_it_back() -> None:
296 """A player reconnecting to the source it already has keeps its session."""
297 controller, provider, _player = _controller(_source())
298 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
299 first = controller.get_audio_source_session(PLAYER_ID)
300
301 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
302
303 provider.on_source_released.assert_not_awaited()
304 assert controller.get_audio_source_session(PLAYER_ID) is first
305
306
307async def test_a_source_that_fails_to_start_is_not_left_on_the_player() -> None:
308 """
309 A failed play command rolls the session back.
310
311 A session left behind would have the player publish a source that never started,
312 and its queue stays inactive with nothing playing it â unreachable from play.
313 """
314 controller, provider, _player = _controller(_source())
315 controller._handle_play_media = AsyncMock(side_effect=PlayerCommandFailed("no route"))
316
317 with pytest.raises(PlayerCommandFailed):
318 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
319
320 assert controller.get_audio_source_session(PLAYER_ID) is None
321 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
322
323
324async def test_an_announcement_does_not_take_the_source_off_the_player() -> None:
325 """
326 An announcement interrupts the player without ending the source session.
327
328 The player is handed straight back afterwards, and a released source cannot be
329 re-selected: its plugin has let go of the upstream session by then.
330 """
331 controller, provider, _player = _controller(_source())
332 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
333 session = controller.get_audio_source_session(PLAYER_ID)
334 controller._handle_play_media = PlayerController._handle_play_media.__get__(controller)
335 controller.get_player = MagicMock(return_value=_player)
336 _player.play_media = AsyncMock()
337
338 with suppress(Exception):
339 await controller._handle_play_media(
340 PLAYER_ID,
341 PlayerMedia(uri="http://x/announce.mp3", media_type=MediaType.ANNOUNCEMENT),
342 )
343
344 assert controller.get_audio_source_session(PLAYER_ID) is session
345 provider.on_source_released.assert_not_awaited()
346
347
348async def test_ordinary_media_does_take_the_source_off_the_player() -> None:
349 """Anything that is not transient ends the session, so the source is handed back."""
350 controller, provider, _player = _controller(_source())
351 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
352 controller._handle_play_media = PlayerController._handle_play_media.__get__(controller)
353 controller.get_player = MagicMock(return_value=_player)
354 _player.play_media = AsyncMock()
355
356 with suppress(Exception):
357 await controller._handle_play_media(
358 PLAYER_ID,
359 PlayerMedia(uri="library://track/1", media_type=MediaType.TRACK),
360 )
361
362 assert controller.get_audio_source_session(PLAYER_ID) is None
363 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
364
365
366async def test_a_plugin_unloading_releases_the_sources_it_owns() -> None:
367 """
368 A plugin going away takes its sources off the players playing them.
369
370 A session outliving its provider leaves the player naming a source that can no
371 longer be streamed, and the queue behind it stays inactive.
372 """
373 controller, provider, _player = _controller(_source())
374 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
375 assert controller.get_audio_source_session(PLAYER_ID) is not None
376
377 await controller.release_provider_sources(PROVIDER_INSTANCE)
378
379 assert controller.get_audio_source_session(PLAYER_ID) is None
380 provider.on_source_released.assert_awaited_once_with("main", PLAYER_ID)
381
382
383async def test_another_plugin_unloading_leaves_the_session_alone() -> None:
384 """Only the sources of the plugin that is going away are given back."""
385 controller, provider, _player = _controller(_source())
386 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
387 session = controller.get_audio_source_session(PLAYER_ID)
388
389 await controller.release_provider_sources("some_other_provider--1")
390
391 assert controller.get_audio_source_session(PLAYER_ID) is session
392 provider.on_source_released.assert_not_awaited()
393
394
395async def test_the_reported_media_can_be_handed_back_to_the_player() -> None:
396 """
397 What the player reports it is playing carries the session token.
398
399 The announcement restore hands this object straight back to the player, and the
400 stream url cannot be resolved without the token the session is keyed on.
401 """
402 controller, _provider, _player = _controller(_source())
403 await controller._handle_select_source(PLAYER_ID, SOURCE_URI)
404 session = controller.get_audio_source_session(PLAYER_ID)
405 assert session is not None
406
407 media = controller._handle_play_media.await_args.args[1]
408 reported = Player._Player__audio_source_media(_player, session) # type: ignore[attr-defined]
409
410 assert reported.queue_session_id == session.playback_session_id
411 assert reported.source_id == media.source_id
412