/
/
/
1"""
2Tests for the per-player AudioSource session record.
3
4The session holds what an external source is, who owns it and what it reports,
5without any of it living in a queue item. Nothing produces a session yet, so
6these tests drive the mixin directly.
7"""
8
9from typing import cast
10from unittest.mock import MagicMock
11
12from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType
13from music_assistant_models.media_items import AudioFormat, AudioSource
14from music_assistant_models.media_items.provider_mapping import ProviderMapping
15from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
16
17from music_assistant.controllers.players import PlayerController
18from music_assistant.controllers.players.audio_sources import (
19 AudioSourceMixin,
20 AudioSourceSession,
21)
22from music_assistant.models.music_provider import MusicProvider
23from music_assistant.models.plugin import PluginProvider
24
25PLAYER_ID = "player-1"
26PROVIDER_INSTANCE = "spotify_connect--abc"
27SOURCE_ID = "main"
28
29
30def _audio_source(item_id: str = SOURCE_ID, *, can_seek: bool = False) -> AudioSource:
31 return AudioSource(
32 item_id=item_id,
33 provider=PROVIDER_INSTANCE,
34 name="Spotify Connect",
35 provider_mappings={
36 ProviderMapping(
37 item_id=item_id,
38 provider_domain="spotify_connect",
39 provider_instance=PROVIDER_INSTANCE,
40 )
41 },
42 can_play_pause=True,
43 can_seek=can_seek,
44 )
45
46
47class _Controller(AudioSourceMixin):
48 """Minimal stand-in for the parts of PlayerController the mixin relies on."""
49
50 def __init__(self, provider: object | None) -> None:
51 self._source_sessions: dict[str, AudioSourceSession] = {}
52 self.mass = MagicMock()
53 self.mass.get_provider.return_value = provider
54 # kept under its own name so assertions can read the mock's call record
55 self.log_mock = MagicMock()
56 self.logger = self.log_mock
57 self.updated_players: list[str] = []
58
59 def trigger_player_update(self, player_id: str) -> None:
60 """Record that a player update was signalled."""
61 self.updated_players.append(player_id)
62
63
64def _streamdetails(metadata: StreamMetadata) -> StreamDetails:
65 return StreamDetails(
66 provider=PROVIDER_INSTANCE,
67 item_id=SOURCE_ID,
68 audio_format=AudioFormat(content_type=ContentType.PCM_S16LE),
69 media_type=MediaType.AUDIO_SOURCE,
70 stream_type=StreamType.CUSTOM,
71 stream_metadata=metadata,
72 )
73
74
75def _plugin_provider(*, with_feature: bool = True) -> MagicMock:
76 provider = MagicMock(spec=PluginProvider)
77 provider.instance_id = PROVIDER_INSTANCE
78 provider.supported_features = {ProviderFeature.AUDIO_SOURCE} if with_feature else set()
79 return provider
80
81
82def test_no_session_by_default() -> None:
83 """A player with nothing playing on it has no session and no source."""
84 ctrl = _Controller(_plugin_provider())
85 assert ctrl.get_audio_source_session(PLAYER_ID) is None
86 assert ctrl.get_player_audio_source(PLAYER_ID) is None
87
88
89def test_started_session_resolves_source_and_provider() -> None:
90 """A started session resolves to its AudioSource and owning plugin."""
91 provider = _plugin_provider()
92 ctrl = _Controller(provider)
93 source = _audio_source()
94 session = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE)
95
96 assert session.player_id == PLAYER_ID
97 assert session.source_id == SOURCE_ID
98 assert session.streamdetails is None
99 assert session.stream_metadata is None
100 assert session.stream_session_id is None
101 assert session.started_at > 0
102
103 assert ctrl.get_audio_source_session(PLAYER_ID) is session
104 assert ctrl.get_player_audio_source(PLAYER_ID) == (source, provider)
105
106
107def test_starting_a_second_session_replaces_the_first() -> None:
108 """A player outputs one source at a time, so a new session replaces the old."""
109 ctrl = _Controller(_plugin_provider())
110 first = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("first"), PROVIDER_INSTANCE)
111 second = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("second"), PROVIDER_INSTANCE)
112
113 current = ctrl.get_audio_source_session(PLAYER_ID)
114 assert current is second
115 assert current.source_id == "second"
116 cast("MagicMock", ctrl.mass.streams.audio_processing.clear_source).assert_called_once_with(
117 PLAYER_ID, first.playback_session_id
118 )
119
120
121def test_ending_a_session_drops_and_returns_it() -> None:
122 """Ending a session removes it from the player and hands it back."""
123 ctrl = _Controller(_plugin_provider())
124 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
125
126 assert ctrl._end_audio_source_session(PLAYER_ID) is session
127 cast("MagicMock", ctrl.mass.streams.audio_processing.clear_source).assert_called_once_with(
128 PLAYER_ID, session.playback_session_id
129 )
130 assert ctrl.get_audio_source_session(PLAYER_ID) is None
131 # ending twice is harmless
132 assert ctrl._end_audio_source_session(PLAYER_ID) is None
133
134
135def test_sessions_are_isolated_per_player() -> None:
136 """A session on one player is invisible to another."""
137 ctrl = _Controller(_plugin_provider())
138 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
139
140 assert ctrl.get_audio_source_session("player-2") is None
141 assert ctrl.get_player_audio_source("player-2") is None
142
143
144def test_source_unresolvable_when_provider_is_gone() -> None:
145 """A session whose plugin has unloaded resolves to nothing."""
146 ctrl = _Controller(None)
147 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
148
149 assert ctrl.get_audio_source_session(PLAYER_ID) is not None
150 assert ctrl.get_player_audio_source(PLAYER_ID) is None
151
152
153def test_source_unresolvable_when_the_feature_was_turned_off() -> None:
154 """A provider that dropped the AUDIO_SOURCE feature at runtime is skipped."""
155 ctrl = _Controller(_plugin_provider(with_feature=False))
156 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
157
158 assert ctrl.get_player_audio_source(PLAYER_ID) is None
159
160
161def test_source_unresolvable_when_the_provider_is_not_a_plugin() -> None:
162 """
163 A non-PluginProvider behind the instance id is skipped.
164
165 It declares the AUDIO_SOURCE feature so the feature guard cannot be what
166 rejects it â only the isinstance check can.
167 """
168 not_a_plugin = MagicMock(spec=MusicProvider)
169 not_a_plugin.supported_features = {ProviderFeature.AUDIO_SOURCE}
170 ctrl = _Controller(not_a_plugin)
171 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
172
173 assert ctrl.get_player_audio_source(PLAYER_ID) is None
174
175
176def test_metadata_update_is_accepted_before_streamdetails_exist() -> None:
177 """The owning plugin can replay what it knows the moment it claims the player."""
178 ctrl = _Controller(_plugin_provider())
179 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
180 metadata = StreamMetadata(title="Take Five", artist="Dave Brubeck")
181
182 ctrl.update_source_metadata(PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, metadata)
183
184 assert session.streamdetails is None
185 assert session.stream_metadata is metadata
186 assert session.stream_metadata_last_updated is not None
187 assert ctrl.updated_players == [PLAYER_ID]
188
189
190def test_metadata_update_rejected_for_another_source() -> None:
191 """Metadata for a source that is not the one playing is dropped."""
192 ctrl = _Controller(_plugin_provider())
193 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
194
195 ctrl.update_source_metadata(
196 PLAYER_ID, "some-other-source", PROVIDER_INSTANCE, StreamMetadata(title="Nope")
197 )
198
199 assert session.stream_metadata is None
200 assert ctrl.updated_players == []
201 assert ctrl.log_mock.debug.called
202
203
204def test_metadata_update_rejected_for_another_provider() -> None:
205 """Metadata from a provider that does not own the session is dropped."""
206 ctrl = _Controller(_plugin_provider())
207 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
208
209 ctrl.update_source_metadata(
210 PLAYER_ID, SOURCE_ID, "airplay_receiver--xyz", StreamMetadata(title="Nope")
211 )
212
213 assert session.stream_metadata is None
214 assert ctrl.updated_players == []
215 assert ctrl.log_mock.debug.called
216
217
218def test_metadata_update_rejected_when_nothing_is_playing() -> None:
219 """Metadata for a player with no session is dropped without raising."""
220 ctrl = _Controller(_plugin_provider())
221
222 ctrl.update_source_metadata(
223 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Nope")
224 )
225
226 assert ctrl.get_audio_source_session(PLAYER_ID) is None
227 assert ctrl.updated_players == []
228 assert ctrl.log_mock.debug.called
229
230
231def test_reconnecting_the_same_source_keeps_its_metadata() -> None:
232 """
233 A drop and reconnect re-stamps the stream token without losing what was known.
234
235 The queue-borne path reuses the cached streamdetails on a reconnect, so the
236 reported track survives one; a fresh session per stream request would blank it
237 until the plugin happened to push again.
238 """
239 ctrl = _Controller(_plugin_provider())
240 source = _audio_source()
241 first = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE)
242 audio_details = MagicMock()
243 first.active_source_audio = audio_details
244 first_session_id = first.playback_session_id
245 ctrl.update_source_metadata(
246 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
247 )
248
249 again = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE)
250
251 assert again is first
252 assert again.stream_metadata is not None
253 assert again.stream_metadata.title == "Take Five"
254 assert again.started_at == first.started_at
255 assert again.active_source_audio is audio_details
256 cast("MagicMock", ctrl.mass.streams.audio_processing.clear_source).assert_called_once_with(
257 PLAYER_ID,
258 first_session_id,
259 preserve_details=True,
260 )
261
262
263def test_selecting_a_different_source_does_not_keep_the_old_metadata() -> None:
264 """A different source is a new session, so nothing carries over."""
265 ctrl = _Controller(_plugin_provider())
266 ctrl._start_audio_source_session(PLAYER_ID, _audio_source("first"), PROVIDER_INSTANCE)
267 ctrl.update_source_metadata(
268 PLAYER_ID, "first", PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
269 )
270
271 second = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("second"), PROVIDER_INSTANCE)
272
273 assert second.source_id == "second"
274 assert second.stream_metadata is None
275
276
277def test_attaching_streamdetails_adopts_their_metadata() -> None:
278 """The placeholder a plugin sets in get_stream_details is what the session reports."""
279 ctrl = _Controller(_plugin_provider())
280 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
281
282 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Studio")))
283
284 assert session.streamdetails is not None
285 assert session.stream_metadata is not None
286 assert session.stream_metadata.title == "VBAN | Studio"
287 assert session.stream_metadata_last_updated is not None
288
289
290def test_attaching_streamdetails_does_not_overwrite_a_reported_track() -> None:
291 """A source that already reported something keeps it: the placeholder is only a fallback."""
292 ctrl = _Controller(_plugin_provider())
293 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
294 ctrl.update_source_metadata(
295 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
296 )
297
298 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
299
300 assert session.stream_metadata is not None
301 assert session.stream_metadata.title == "Take Five"
302
303
304def test_source_id_is_provider_scoped_and_uri_is_unique() -> None:
305 """Every shipped plugin names its only source "main", so the uri is the unique handle."""
306 ctrl = _Controller(_plugin_provider())
307 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
308
309 assert session.source_id == "main"
310 assert session.source_uri != session.source_id
311 assert session.source_uri is not None
312 assert PROVIDER_INSTANCE in session.source_uri
313
314
315def test_mixin_is_attached_to_the_real_player_controller() -> None:
316 """The carrier is wired into PlayerController and its store is initialised."""
317 mass = MagicMock()
318 mass.config.get_raw_core_config_value.return_value = "INFO"
319 controller = PlayerController(mass)
320
321 assert isinstance(controller, AudioSourceMixin)
322 assert controller._source_sessions == {}
323 assert controller.get_audio_source_session("no-such-player") is None
324
325
326def test_reselecting_adopts_a_rebuilt_source() -> None:
327 """
328 A plugin that rebuilds its source with new capabilities gets those reported.
329
330 spotify_connect and yandex_ynison rebuild the AudioSource whenever a
331 capability flag changes; ynison's _update_source_capabilities even overwrites
332 the queue item's snapshot so the new flags reach the UI without waiting for
333 the next play. A session that kept the first object would report stale flags.
334 """
335 ctrl = _Controller(_plugin_provider())
336 session = ctrl._start_audio_source_session(
337 PLAYER_ID, _audio_source(can_seek=False), PROVIDER_INSTANCE
338 )
339 ctrl.update_source_metadata(
340 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
341 )
342
343 rebuilt = _audio_source(can_seek=True)
344 again = ctrl._start_audio_source_session(PLAYER_ID, rebuilt, PROVIDER_INSTANCE)
345
346 assert again is session
347 assert again.source is rebuilt
348 assert again.source.can_seek is True
349 # the reported track still survives the reselect
350 assert again.stream_metadata is not None
351 assert again.stream_metadata.title == "Take Five"
352
353
354def test_a_fallback_only_source_follows_a_changed_placeholder() -> None:
355 """
356 A source that never reports keeps taking its placeholder from the stream details.
357
358 vban_receiver and sendspin_source never call update_stream_metadata, so the
359 placeholder is all they have. Adopting one must not stop a later one landing:
360 a VBAN reconnect from a different sender describes something else.
361 """
362 ctrl = _Controller(_plugin_provider())
363 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
364
365 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Studio")))
366 assert session.stream_metadata is not None
367 assert session.stream_metadata.title == "VBAN | Studio"
368
369 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Booth")))
370 assert session.stream_metadata.title == "VBAN | Booth"
371 assert session.stream_metadata_reported is False
372
373
374def test_claiming_a_session_stamps_the_stream_token() -> None:
375 """A stream request claiming the live session becomes the one serving it."""
376 ctrl = _Controller(_plugin_provider())
377 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
378
379 assert ctrl.claim_audio_source_session(session, session.playback_session_id, "tok-1") is True
380 assert session.stream_session_id == "tok-1"
381
382
383def test_a_reconnect_does_not_take_away_a_player_the_source_is_moving_to() -> None:
384 """
385 Only the first stream request for a selection evicts the other players.
386
387 A player already streaming the source reconnects through the same claim, and
388 it must not undo a handover whose target has not started streaming yet.
389 """
390 ctrl = _Controller(_plugin_provider())
391 source = _audio_source()
392 session_a = ctrl._start_audio_source_session("player-a", source, PROVIDER_INSTANCE)
393 ctrl.claim_audio_source_session(session_a, session_a.playback_session_id, "tok-a1")
394 session_b = ctrl._start_audio_source_session("player-b", source, PROVIDER_INSTANCE)
395
396 # player-a's renderer drops and re-opens the stream while the move is pending
397 assert ctrl.claim_audio_source_session(session_a, session_a.playback_session_id, "tok-a2")
398
399 assert ctrl.get_audio_source_session("player-b") is session_b
400 assert "player-b" not in ctrl.updated_players
401
402 # the handover still completes once player-b actually streams
403 assert ctrl.claim_audio_source_session(session_b, session_b.playback_session_id, "tok-b1")
404
405 assert ctrl.get_audio_source_session("player-a") is None
406 assert ctrl.updated_players == ["player-a"]
407
408
409def test_a_claimed_token_outlives_the_stream_that_stamped_it() -> None:
410 """
411 The stream token records that a selection was streamed, not that it still is.
412
413 A paused source keeps the player while its stream is torn down, and what
414 separates it from one that was never streamed at all is this token.
415 """
416 ctrl = _Controller(_plugin_provider())
417 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
418 ctrl.claim_audio_source_session(session, session.playback_session_id, "tok-1")
419
420 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Take Five")))
421
422 assert session.stream_session_id == "tok-1"
423
424
425def test_claiming_a_superseded_session_is_refused() -> None:
426 """A stream request set up for a session the player has left behind is turned away."""
427 ctrl = _Controller(_plugin_provider())
428 first = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("first"), PROVIDER_INSTANCE)
429 second = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("second"), PROVIDER_INSTANCE)
430
431 assert ctrl.claim_audio_source_session(first, first.playback_session_id, "tok-1") is False
432 assert first.stream_session_id is None
433 # the live session is not claimable with a stale playback token either
434 assert ctrl.claim_audio_source_session(second, "a-token-from-before", "tok-2") is False
435 assert second.stream_session_id is None
436
437
438def test_a_handover_evicts_the_old_player_only_on_claim() -> None:
439 """
440 A source moving between players leaves the old one only when the stream claims it.
441
442 Starting the new session is not the commit: a takeover that never produces a
443 stream request must leave the source untouched on the player that has it.
444 """
445 ctrl = _Controller(_plugin_provider())
446 source = _audio_source()
447 session_a = ctrl._start_audio_source_session("player-a", source, PROVIDER_INSTANCE)
448 session_b = ctrl._start_audio_source_session("player-b", source, PROVIDER_INSTANCE)
449
450 clear_source = cast("MagicMock", ctrl.mass.streams.audio_processing.clear_source)
451 assert ctrl.get_audio_source_session("player-a") is session_a
452 assert ctrl.get_audio_source_session("player-b") is session_b
453 assert "player-a" not in ctrl.updated_players
454 clear_source.assert_not_called()
455
456 assert ctrl.claim_audio_source_session(session_b, session_b.playback_session_id, "tok-1")
457
458 assert ctrl.get_audio_source_session("player-a") is None
459 assert ctrl.updated_players == ["player-a"]
460 clear_source.assert_called_once_with("player-a", session_a.playback_session_id)
461
462 # a renderer reconnect re-claims the same session without another eviction
463 assert ctrl.claim_audio_source_session(session_b, session_b.playback_session_id, "tok-2")
464
465 assert ctrl.updated_players == ["player-a"]
466 clear_source.assert_called_once()
467
468
469def test_a_reported_track_is_not_replaced_by_a_later_placeholder() -> None:
470 """Once the source has reported, stream details stop overriding it."""
471 ctrl = _Controller(_plugin_provider())
472 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
473 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
474
475 ctrl.update_source_metadata(
476 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
477 )
478 assert session.stream_metadata_reported is True
479
480 # a reconnect brings fresh stream details carrying only the placeholder again
481 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
482
483 assert session.stream_metadata is not None
484 assert session.stream_metadata.title == "Take Five"
485
486
487def test_live_source_recognised_by_the_uri_it_publishes() -> None:
488 """The uri a session publishes as active source is recognised as MA's own."""
489 ctrl = _Controller(_plugin_provider())
490 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
491 assert session.source_uri is not None
492
493 assert ctrl.is_live_audio_source(session.source_uri)
494 assert not ctrl.is_live_audio_source("tv")
495 assert not ctrl.is_live_audio_source(PLAYER_ID)
496
497 ctrl._end_audio_source_session(PLAYER_ID)
498 assert not ctrl.is_live_audio_source(session.source_uri)
499