/
/
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_a_reported_track_is_not_replaced_by_a_later_placeholder() -> None:
375 """Once the source has reported, stream details stop overriding it."""
376 ctrl = _Controller(_plugin_provider())
377 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
378 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
379
380 ctrl.update_source_metadata(
381 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
382 )
383 assert session.stream_metadata_reported is True
384
385 # a reconnect brings fresh stream details carrying only the placeholder again
386 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
387
388 assert session.stream_metadata is not None
389 assert session.stream_metadata.title == "Take Five"
390