/
/
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 unittest.mock import MagicMock
10
11from music_assistant_models.enums import ContentType, MediaType, ProviderFeature, StreamType
12from music_assistant_models.media_items import AudioFormat, AudioSource
13from music_assistant_models.media_items.provider_mapping import ProviderMapping
14from music_assistant_models.streamdetails import StreamDetails, StreamMetadata
15
16from music_assistant.controllers.players import PlayerController
17from music_assistant.controllers.players.audio_sources import (
18 AudioSourceMixin,
19 AudioSourceSession,
20)
21from music_assistant.models.music_provider import MusicProvider
22from music_assistant.models.plugin import PluginProvider
23
24PLAYER_ID = "player-1"
25PROVIDER_INSTANCE = "spotify_connect--abc"
26SOURCE_ID = "main"
27
28
29def _audio_source(item_id: str = SOURCE_ID, *, can_seek: bool = False) -> AudioSource:
30 return AudioSource(
31 item_id=item_id,
32 provider=PROVIDER_INSTANCE,
33 name="Spotify Connect",
34 provider_mappings={
35 ProviderMapping(
36 item_id=item_id,
37 provider_domain="spotify_connect",
38 provider_instance=PROVIDER_INSTANCE,
39 )
40 },
41 can_play_pause=True,
42 can_seek=can_seek,
43 )
44
45
46class _Controller(AudioSourceMixin):
47 """Minimal stand-in for the parts of PlayerController the mixin relies on."""
48
49 def __init__(self, provider: object | None) -> None:
50 self._source_sessions: dict[str, AudioSourceSession] = {}
51 self.mass = MagicMock()
52 self.mass.get_provider.return_value = provider
53 # kept under its own name so assertions can read the mock's call record
54 self.log_mock = MagicMock()
55 self.logger = self.log_mock
56 self.updated_players: list[str] = []
57
58 def trigger_player_update(self, player_id: str) -> None:
59 """Record that a player update was signalled."""
60 self.updated_players.append(player_id)
61
62
63def _streamdetails(metadata: StreamMetadata) -> StreamDetails:
64 return StreamDetails(
65 provider=PROVIDER_INSTANCE,
66 item_id=SOURCE_ID,
67 audio_format=AudioFormat(content_type=ContentType.PCM_S16LE),
68 media_type=MediaType.AUDIO_SOURCE,
69 stream_type=StreamType.CUSTOM,
70 stream_metadata=metadata,
71 )
72
73
74def _plugin_provider(*, with_feature: bool = True) -> MagicMock:
75 provider = MagicMock(spec=PluginProvider)
76 provider.instance_id = PROVIDER_INSTANCE
77 provider.supported_features = {ProviderFeature.AUDIO_SOURCE} if with_feature else set()
78 return provider
79
80
81def test_no_session_by_default() -> None:
82 """A player with nothing playing on it has no session and no source."""
83 ctrl = _Controller(_plugin_provider())
84 assert ctrl.get_audio_source_session(PLAYER_ID) is None
85 assert ctrl.get_player_audio_source(PLAYER_ID) is None
86
87
88def test_started_session_resolves_source_and_provider() -> None:
89 """A started session resolves to its AudioSource and owning plugin."""
90 provider = _plugin_provider()
91 ctrl = _Controller(provider)
92 source = _audio_source()
93 session = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE)
94
95 assert session.player_id == PLAYER_ID
96 assert session.source_id == SOURCE_ID
97 assert session.streamdetails is None
98 assert session.stream_metadata is None
99 assert session.stream_session_id is None
100 assert session.started_at > 0
101
102 assert ctrl.get_audio_source_session(PLAYER_ID) is session
103 assert ctrl.get_player_audio_source(PLAYER_ID) == (source, provider)
104
105
106def test_starting_a_second_session_replaces_the_first() -> None:
107 """A player outputs one source at a time, so a new session replaces the old."""
108 ctrl = _Controller(_plugin_provider())
109 ctrl._start_audio_source_session(PLAYER_ID, _audio_source("first"), PROVIDER_INSTANCE)
110 second = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("second"), PROVIDER_INSTANCE)
111
112 current = ctrl.get_audio_source_session(PLAYER_ID)
113 assert current is second
114 assert current.source_id == "second"
115
116
117def test_ending_a_session_drops_and_returns_it() -> None:
118 """Ending a session removes it from the player and hands it back."""
119 ctrl = _Controller(_plugin_provider())
120 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
121
122 assert ctrl._end_audio_source_session(PLAYER_ID) is session
123 assert ctrl.get_audio_source_session(PLAYER_ID) is None
124 # ending twice is harmless
125 assert ctrl._end_audio_source_session(PLAYER_ID) is None
126
127
128def test_ending_a_session_by_stream_token_requires_a_match() -> None:
129 """A teardown scoped to a stream only ends the session holding that stream."""
130 ctrl = _Controller(_plugin_provider())
131 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
132 session.stream_session_id = "stream-a"
133
134 assert ctrl._end_audio_source_session(PLAYER_ID, "stream-b") is None
135 assert ctrl.get_audio_source_session(PLAYER_ID) is session
136
137 assert ctrl._end_audio_source_session(PLAYER_ID, "stream-a") is session
138 assert ctrl.get_audio_source_session(PLAYER_ID) is None
139
140
141def test_a_superseded_teardown_does_not_end_its_replacement() -> None:
142 """
143 A reconnect's late teardown must not drop the session that replaced it.
144
145 Replays the ordering the plugin contract calls out: the player drops and
146 reopens the stream, so the first request's teardown runs after the second
147 request has already claimed the player.
148 """
149 ctrl = _Controller(_plugin_provider())
150 first = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
151 first.stream_session_id = "stream-a"
152
153 # the player reconnects: a new stream claims the player before the old one tears down
154 second = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
155 second.stream_session_id = "stream-b"
156
157 # ...and only now does the first request's finally block run
158 assert ctrl._end_audio_source_session(PLAYER_ID, "stream-a") is None
159 assert ctrl.get_audio_source_session(PLAYER_ID) is second
160
161
162def test_ending_a_session_without_a_token_is_unconditional() -> None:
163 """A deselect that is not scoped to a stream ends whatever is playing."""
164 ctrl = _Controller(_plugin_provider())
165 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
166 session.stream_session_id = "stream-a"
167
168 assert ctrl._end_audio_source_session(PLAYER_ID) is session
169 assert ctrl.get_audio_source_session(PLAYER_ID) is None
170
171
172def test_sessions_are_isolated_per_player() -> None:
173 """A session on one player is invisible to another."""
174 ctrl = _Controller(_plugin_provider())
175 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
176
177 assert ctrl.get_audio_source_session("player-2") is None
178 assert ctrl.get_player_audio_source("player-2") is None
179
180
181def test_source_unresolvable_when_provider_is_gone() -> None:
182 """A session whose plugin has unloaded resolves to nothing."""
183 ctrl = _Controller(None)
184 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
185
186 assert ctrl.get_audio_source_session(PLAYER_ID) is not None
187 assert ctrl.get_player_audio_source(PLAYER_ID) is None
188
189
190def test_source_unresolvable_when_the_feature_was_turned_off() -> None:
191 """A provider that dropped the AUDIO_SOURCE feature at runtime is skipped."""
192 ctrl = _Controller(_plugin_provider(with_feature=False))
193 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
194
195 assert ctrl.get_player_audio_source(PLAYER_ID) is None
196
197
198def test_source_unresolvable_when_the_provider_is_not_a_plugin() -> None:
199 """
200 A non-PluginProvider behind the instance id is skipped.
201
202 It declares the AUDIO_SOURCE feature so the feature guard cannot be what
203 rejects it â only the isinstance check can.
204 """
205 not_a_plugin = MagicMock(spec=MusicProvider)
206 not_a_plugin.supported_features = {ProviderFeature.AUDIO_SOURCE}
207 ctrl = _Controller(not_a_plugin)
208 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
209
210 assert ctrl.get_player_audio_source(PLAYER_ID) is None
211
212
213def test_metadata_update_is_accepted_before_streamdetails_exist() -> None:
214 """The owning plugin can replay what it knows the moment it claims the player."""
215 ctrl = _Controller(_plugin_provider())
216 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
217 metadata = StreamMetadata(title="Take Five", artist="Dave Brubeck")
218
219 ctrl.update_source_metadata(PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, metadata)
220
221 assert session.streamdetails is None
222 assert session.stream_metadata is metadata
223 assert session.stream_metadata_last_updated is not None
224 assert ctrl.updated_players == [PLAYER_ID]
225
226
227def test_metadata_update_rejected_for_another_source() -> None:
228 """Metadata for a source that is not the one playing is dropped."""
229 ctrl = _Controller(_plugin_provider())
230 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
231
232 ctrl.update_source_metadata(
233 PLAYER_ID, "some-other-source", PROVIDER_INSTANCE, StreamMetadata(title="Nope")
234 )
235
236 assert session.stream_metadata is None
237 assert ctrl.updated_players == []
238 assert ctrl.log_mock.debug.called
239
240
241def test_metadata_update_rejected_for_another_provider() -> None:
242 """Metadata from a provider that does not own the session is dropped."""
243 ctrl = _Controller(_plugin_provider())
244 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
245
246 ctrl.update_source_metadata(
247 PLAYER_ID, SOURCE_ID, "airplay_receiver--xyz", StreamMetadata(title="Nope")
248 )
249
250 assert session.stream_metadata is None
251 assert ctrl.updated_players == []
252 assert ctrl.log_mock.debug.called
253
254
255def test_metadata_update_rejected_when_nothing_is_playing() -> None:
256 """Metadata for a player with no session is dropped without raising."""
257 ctrl = _Controller(_plugin_provider())
258
259 ctrl.update_source_metadata(
260 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Nope")
261 )
262
263 assert ctrl.get_audio_source_session(PLAYER_ID) is None
264 assert ctrl.updated_players == []
265 assert ctrl.log_mock.debug.called
266
267
268def test_reconnecting_the_same_source_keeps_its_metadata() -> None:
269 """
270 A drop and reconnect re-stamps the stream token without losing what was known.
271
272 The queue-borne path reuses the cached streamdetails on a reconnect, so the
273 reported track survives one; a fresh session per stream request would blank it
274 until the plugin happened to push again.
275 """
276 ctrl = _Controller(_plugin_provider())
277 source = _audio_source()
278 first = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE, "stream-a")
279 ctrl.update_source_metadata(
280 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
281 )
282
283 again = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE, "stream-b")
284
285 assert again is first
286 assert again.stream_session_id == "stream-b"
287 assert again.stream_metadata is not None
288 assert again.stream_metadata.title == "Take Five"
289 assert again.started_at == first.started_at
290
291
292def test_selecting_a_different_source_does_not_keep_the_old_metadata() -> None:
293 """A different source is a new session, so nothing carries over."""
294 ctrl = _Controller(_plugin_provider())
295 ctrl._start_audio_source_session(PLAYER_ID, _audio_source("first"), PROVIDER_INSTANCE)
296 ctrl.update_source_metadata(
297 PLAYER_ID, "first", PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
298 )
299
300 second = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("second"), PROVIDER_INSTANCE)
301
302 assert second.source_id == "second"
303 assert second.stream_metadata is None
304
305
306def test_attaching_streamdetails_adopts_their_metadata() -> None:
307 """The placeholder a plugin sets in get_stream_details is what the session reports."""
308 ctrl = _Controller(_plugin_provider())
309 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
310
311 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Studio")))
312
313 assert session.streamdetails is not None
314 assert session.stream_metadata is not None
315 assert session.stream_metadata.title == "VBAN | Studio"
316 assert session.stream_metadata_last_updated is not None
317
318
319def test_attaching_streamdetails_does_not_overwrite_a_reported_track() -> None:
320 """A source that already reported something keeps it: the placeholder is only a fallback."""
321 ctrl = _Controller(_plugin_provider())
322 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
323 ctrl.update_source_metadata(
324 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
325 )
326
327 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
328
329 assert session.stream_metadata is not None
330 assert session.stream_metadata.title == "Take Five"
331
332
333def test_source_id_is_provider_scoped_and_uri_is_unique() -> None:
334 """Every shipped plugin names its only source "main", so the uri is the unique handle."""
335 ctrl = _Controller(_plugin_provider())
336 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
337
338 assert session.source_id == "main"
339 assert session.source_uri != session.source_id
340 assert session.source_uri is not None
341 assert PROVIDER_INSTANCE in session.source_uri
342
343
344def test_mixin_is_attached_to_the_real_player_controller() -> None:
345 """The carrier is wired into PlayerController and its store is initialised."""
346 mass = MagicMock()
347 mass.config.get_raw_core_config_value.return_value = "INFO"
348 controller = PlayerController(mass)
349
350 assert isinstance(controller, AudioSourceMixin)
351 assert controller._source_sessions == {}
352 assert controller.get_audio_source_session("no-such-player") is None
353
354
355def test_reselecting_adopts_a_rebuilt_source() -> None:
356 """
357 A plugin that rebuilds its source with new capabilities gets those reported.
358
359 spotify_connect and yandex_ynison rebuild the AudioSource whenever a
360 capability flag changes; ynison's _update_source_capabilities even overwrites
361 the queue item's snapshot so the new flags reach the UI without waiting for
362 the next play. A session that kept the first object would report stale flags.
363 """
364 ctrl = _Controller(_plugin_provider())
365 session = ctrl._start_audio_source_session(
366 PLAYER_ID, _audio_source(can_seek=False), PROVIDER_INSTANCE
367 )
368 ctrl.update_source_metadata(
369 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
370 )
371
372 rebuilt = _audio_source(can_seek=True)
373 again = ctrl._start_audio_source_session(PLAYER_ID, rebuilt, PROVIDER_INSTANCE)
374
375 assert again is session
376 assert again.source is rebuilt
377 assert again.source.can_seek is True
378 # the reported track still survives the reselect
379 assert again.stream_metadata is not None
380 assert again.stream_metadata.title == "Take Five"
381
382
383def test_a_fallback_only_source_follows_a_changed_placeholder() -> None:
384 """
385 A source that never reports keeps taking its placeholder from the stream details.
386
387 vban_receiver and sendspin_source never call update_stream_metadata, so the
388 placeholder is all they have. Adopting one must not stop a later one landing:
389 a VBAN reconnect from a different sender describes something else.
390 """
391 ctrl = _Controller(_plugin_provider())
392 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
393
394 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Studio")))
395 assert session.stream_metadata is not None
396 assert session.stream_metadata.title == "VBAN | Studio"
397
398 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Booth")))
399 assert session.stream_metadata.title == "VBAN | Booth"
400 assert session.stream_metadata_reported is False
401
402
403def test_a_reported_track_is_not_replaced_by_a_later_placeholder() -> None:
404 """Once the source has reported, stream details stop overriding it."""
405 ctrl = _Controller(_plugin_provider())
406 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
407 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
408
409 ctrl.update_source_metadata(
410 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
411 )
412 assert session.stream_metadata_reported is True
413
414 # a reconnect brings fresh stream details carrying only the placeholder again
415 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
416
417 assert session.stream_metadata is not None
418 assert session.stream_metadata.title == "Take Five"
419