/
/
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_sessions_are_isolated_per_player() -> None:
129 """A session on one player is invisible to another."""
130 ctrl = _Controller(_plugin_provider())
131 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
132
133 assert ctrl.get_audio_source_session("player-2") is None
134 assert ctrl.get_player_audio_source("player-2") is None
135
136
137def test_source_unresolvable_when_provider_is_gone() -> None:
138 """A session whose plugin has unloaded resolves to nothing."""
139 ctrl = _Controller(None)
140 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
141
142 assert ctrl.get_audio_source_session(PLAYER_ID) is not None
143 assert ctrl.get_player_audio_source(PLAYER_ID) is None
144
145
146def test_source_unresolvable_when_the_feature_was_turned_off() -> None:
147 """A provider that dropped the AUDIO_SOURCE feature at runtime is skipped."""
148 ctrl = _Controller(_plugin_provider(with_feature=False))
149 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
150
151 assert ctrl.get_player_audio_source(PLAYER_ID) is None
152
153
154def test_source_unresolvable_when_the_provider_is_not_a_plugin() -> None:
155 """
156 A non-PluginProvider behind the instance id is skipped.
157
158 It declares the AUDIO_SOURCE feature so the feature guard cannot be what
159 rejects it â only the isinstance check can.
160 """
161 not_a_plugin = MagicMock(spec=MusicProvider)
162 not_a_plugin.supported_features = {ProviderFeature.AUDIO_SOURCE}
163 ctrl = _Controller(not_a_plugin)
164 ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
165
166 assert ctrl.get_player_audio_source(PLAYER_ID) is None
167
168
169def test_metadata_update_is_accepted_before_streamdetails_exist() -> None:
170 """The owning plugin can replay what it knows the moment it claims the player."""
171 ctrl = _Controller(_plugin_provider())
172 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
173 metadata = StreamMetadata(title="Take Five", artist="Dave Brubeck")
174
175 ctrl.update_source_metadata(PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, metadata)
176
177 assert session.streamdetails is None
178 assert session.stream_metadata is metadata
179 assert session.stream_metadata_last_updated is not None
180 assert ctrl.updated_players == [PLAYER_ID]
181
182
183def test_metadata_update_rejected_for_another_source() -> None:
184 """Metadata for a source that is not the one playing is dropped."""
185 ctrl = _Controller(_plugin_provider())
186 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
187
188 ctrl.update_source_metadata(
189 PLAYER_ID, "some-other-source", PROVIDER_INSTANCE, StreamMetadata(title="Nope")
190 )
191
192 assert session.stream_metadata is None
193 assert ctrl.updated_players == []
194 assert ctrl.log_mock.debug.called
195
196
197def test_metadata_update_rejected_for_another_provider() -> None:
198 """Metadata from a provider that does not own the session is dropped."""
199 ctrl = _Controller(_plugin_provider())
200 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
201
202 ctrl.update_source_metadata(
203 PLAYER_ID, SOURCE_ID, "airplay_receiver--xyz", StreamMetadata(title="Nope")
204 )
205
206 assert session.stream_metadata is None
207 assert ctrl.updated_players == []
208 assert ctrl.log_mock.debug.called
209
210
211def test_metadata_update_rejected_when_nothing_is_playing() -> None:
212 """Metadata for a player with no session is dropped without raising."""
213 ctrl = _Controller(_plugin_provider())
214
215 ctrl.update_source_metadata(
216 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Nope")
217 )
218
219 assert ctrl.get_audio_source_session(PLAYER_ID) is None
220 assert ctrl.updated_players == []
221 assert ctrl.log_mock.debug.called
222
223
224def test_reconnecting_the_same_source_keeps_its_metadata() -> None:
225 """
226 A drop and reconnect re-stamps the stream token without losing what was known.
227
228 The queue-borne path reuses the cached streamdetails on a reconnect, so the
229 reported track survives one; a fresh session per stream request would blank it
230 until the plugin happened to push again.
231 """
232 ctrl = _Controller(_plugin_provider())
233 source = _audio_source()
234 first = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE)
235 ctrl.update_source_metadata(
236 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
237 )
238
239 again = ctrl._start_audio_source_session(PLAYER_ID, source, PROVIDER_INSTANCE)
240
241 assert again is first
242 assert again.stream_metadata is not None
243 assert again.stream_metadata.title == "Take Five"
244 assert again.started_at == first.started_at
245
246
247def test_selecting_a_different_source_does_not_keep_the_old_metadata() -> None:
248 """A different source is a new session, so nothing carries over."""
249 ctrl = _Controller(_plugin_provider())
250 ctrl._start_audio_source_session(PLAYER_ID, _audio_source("first"), PROVIDER_INSTANCE)
251 ctrl.update_source_metadata(
252 PLAYER_ID, "first", PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
253 )
254
255 second = ctrl._start_audio_source_session(PLAYER_ID, _audio_source("second"), PROVIDER_INSTANCE)
256
257 assert second.source_id == "second"
258 assert second.stream_metadata is None
259
260
261def test_attaching_streamdetails_adopts_their_metadata() -> None:
262 """The placeholder a plugin sets in get_stream_details is what the session reports."""
263 ctrl = _Controller(_plugin_provider())
264 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
265
266 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Studio")))
267
268 assert session.streamdetails is not None
269 assert session.stream_metadata is not None
270 assert session.stream_metadata.title == "VBAN | Studio"
271 assert session.stream_metadata_last_updated is not None
272
273
274def test_attaching_streamdetails_does_not_overwrite_a_reported_track() -> None:
275 """A source that already reported something keeps it: the placeholder is only a fallback."""
276 ctrl = _Controller(_plugin_provider())
277 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
278 ctrl.update_source_metadata(
279 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
280 )
281
282 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
283
284 assert session.stream_metadata is not None
285 assert session.stream_metadata.title == "Take Five"
286
287
288def test_source_id_is_provider_scoped_and_uri_is_unique() -> None:
289 """Every shipped plugin names its only source "main", so the uri is the unique handle."""
290 ctrl = _Controller(_plugin_provider())
291 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
292
293 assert session.source_id == "main"
294 assert session.source_uri != session.source_id
295 assert session.source_uri is not None
296 assert PROVIDER_INSTANCE in session.source_uri
297
298
299def test_mixin_is_attached_to_the_real_player_controller() -> None:
300 """The carrier is wired into PlayerController and its store is initialised."""
301 mass = MagicMock()
302 mass.config.get_raw_core_config_value.return_value = "INFO"
303 controller = PlayerController(mass)
304
305 assert isinstance(controller, AudioSourceMixin)
306 assert controller._source_sessions == {}
307 assert controller.get_audio_source_session("no-such-player") is None
308
309
310def test_reselecting_adopts_a_rebuilt_source() -> None:
311 """
312 A plugin that rebuilds its source with new capabilities gets those reported.
313
314 spotify_connect and yandex_ynison rebuild the AudioSource whenever a
315 capability flag changes; ynison's _update_source_capabilities even overwrites
316 the queue item's snapshot so the new flags reach the UI without waiting for
317 the next play. A session that kept the first object would report stale flags.
318 """
319 ctrl = _Controller(_plugin_provider())
320 session = ctrl._start_audio_source_session(
321 PLAYER_ID, _audio_source(can_seek=False), PROVIDER_INSTANCE
322 )
323 ctrl.update_source_metadata(
324 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
325 )
326
327 rebuilt = _audio_source(can_seek=True)
328 again = ctrl._start_audio_source_session(PLAYER_ID, rebuilt, PROVIDER_INSTANCE)
329
330 assert again is session
331 assert again.source is rebuilt
332 assert again.source.can_seek is True
333 # the reported track still survives the reselect
334 assert again.stream_metadata is not None
335 assert again.stream_metadata.title == "Take Five"
336
337
338def test_a_fallback_only_source_follows_a_changed_placeholder() -> None:
339 """
340 A source that never reports keeps taking its placeholder from the stream details.
341
342 vban_receiver and sendspin_source never call update_stream_metadata, so the
343 placeholder is all they have. Adopting one must not stop a later one landing:
344 a VBAN reconnect from a different sender describes something else.
345 """
346 ctrl = _Controller(_plugin_provider())
347 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
348
349 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Studio")))
350 assert session.stream_metadata is not None
351 assert session.stream_metadata.title == "VBAN | Studio"
352
353 session.attach_streamdetails(_streamdetails(StreamMetadata(title="VBAN | Booth")))
354 assert session.stream_metadata.title == "VBAN | Booth"
355 assert session.stream_metadata_reported is False
356
357
358def test_a_reported_track_is_not_replaced_by_a_later_placeholder() -> None:
359 """Once the source has reported, stream details stop overriding it."""
360 ctrl = _Controller(_plugin_provider())
361 session = ctrl._start_audio_source_session(PLAYER_ID, _audio_source(), PROVIDER_INSTANCE)
362 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
363
364 ctrl.update_source_metadata(
365 PLAYER_ID, SOURCE_ID, PROVIDER_INSTANCE, StreamMetadata(title="Take Five")
366 )
367 assert session.stream_metadata_reported is True
368
369 # a reconnect brings fresh stream details carrying only the placeholder again
370 session.attach_streamdetails(_streamdetails(StreamMetadata(title="Spotify Connect | Kitchen")))
371
372 assert session.stream_metadata is not None
373 assert session.stream_metadata.title == "Take Five"
374