/
/
1"""
2Tests for the shuffle state a newly started media item ends up with.
3
4A shuffle left switched on by an earlier listening session must not silently reorder the album the
5user just picked, while a shuffle the user switched on moments before pressing play is a deliberate
6"shuffle this" gesture and has to be honoured. Only the options that start the media right away
7(play and replace) begin such a new listening session; the options that stage items onto the queue
8keep its shuffle state. These drive the real ``play_media`` path against a bare controller instance,
9mirroring ``test_user_initiated_plays`` and ``test_enqueue_options``: resolution and playback are
10stubbed, but the enqueue/load path runs for real so the resulting item order is verified
11end-to-end.
12"""
13
14from __future__ import annotations
15
16import time
17from typing import Any, cast
18from unittest.mock import AsyncMock, MagicMock, Mock
19
20import pytest
21from music_assistant_models.enums import MediaType, QueueOption
22from music_assistant_models.media_items import (
23 Album,
24 ItemMapping,
25 Playlist,
26 ProviderMapping,
27 Track,
28)
29from music_assistant_models.player_queue import PlayerQueue
30from music_assistant_models.queue_item import QueueItem
31from music_assistant_models.unique_list import UniqueList
32
33from music_assistant.controllers.player_queues import PlayerQueuesController
34from music_assistant.controllers.player_queues.constants import SHUFFLE_INTENT_WINDOW
35from music_assistant.controllers.player_queues.state import PlayerQueueData
36
37# the album the user starts, in its own track order
38ALBUM_TRACKS = ["t1", "t2", "t3", "t4"]
39
40# the options that start the media right away, i.e. begin a new listening session
41START_OPTIONS = [QueueOption.PLAY, QueueOption.REPLACE]
42
43# the options that stage media onto the queue instead of starting it
44STAGE_OPTIONS = [QueueOption.ADD, QueueOption.NEXT, QueueOption.REPLACE_NEXT]
45
46# a queue as an earlier shuffle left it: the list order deliberately disagrees with the sort order
47SHUFFLED_QUEUE = [("e1", 0), ("e4", 3), ("e2", 1), ("e5", 4), ("e3", 2)]
48
49# the managed pool a dynamic queue is playing when other media is staged over it
50POOL_TRACKS = ["p1", "p2", "p3"]
51
52
53def _track(item_id: str) -> Track:
54 """Build a playable Track on the 'test' provider."""
55 return Track(
56 item_id=item_id,
57 provider="test",
58 name=f"Track {item_id}",
59 duration=60,
60 artists=UniqueList(
61 [ItemMapping(item_id="a", provider="test", name="A", media_type=MediaType.ARTIST)]
62 ),
63 provider_mappings={
64 ProviderMapping(item_id=item_id, provider_domain="test", provider_instance="test")
65 },
66 )
67
68
69def _album() -> Album:
70 """Build the Album the user presses play on (its configured enqueue default is 'replace')."""
71 return Album(
72 item_id="al1",
73 provider="test",
74 name="Album al1",
75 provider_mappings={
76 ProviderMapping(item_id="al1", provider_domain="test", provider_instance="test")
77 },
78 )
79
80
81def _dynamic_playlist() -> Playlist:
82 """Build a dynamic playlist: a source that supplies its own tracks and is always a smart mix."""
83 playlist = Playlist(
84 item_id="dyn1",
85 provider="test",
86 name="Dynamic",
87 provider_mappings={
88 ProviderMapping(item_id="dyn1", provider_domain="test", provider_instance="test")
89 },
90 )
91 playlist.is_dynamic = True
92 return playlist
93
94
95def _controller(**queue_kwargs: Any) -> Any:
96 """
97 Build a bare controller driving ``play_media`` on a single queue "q1".
98
99 The album's tracks come from a stubbed media resolver and the shuffle is made deterministic
100 (it reverses the batch), so the resulting queue order tells shuffle-on from shuffle-off.
101
102 :param queue_kwargs: Overrides for the queue this controller is set up with.
103 """
104 ctrl = PlayerQueuesController.__new__(PlayerQueuesController)
105 ctrl.logger = Mock()
106 ctrl.mass = MagicMock()
107 ctrl.mass.players.get_player = Mock(return_value=Mock(extra_data={}))
108 lock_cm = MagicMock()
109 lock_cm.__aenter__ = AsyncMock(return_value=None)
110 lock_cm.__aexit__ = AsyncMock(return_value=None)
111 ctrl.mass.players.get_player_lock = Mock(return_value=lock_cm)
112 ctrl.signal_update = Mock() # type: ignore[method-assign]
113 ctrl.on_player_update = Mock() # type: ignore[method-assign]
114 ctrl.play_index = AsyncMock() # type: ignore[method-assign]
115 ctrl.get_next_item = Mock(return_value=None) # type: ignore[method-assign]
116 ctrl.get_config_value = Mock(return_value=QueueOption.REPLACE.value) # type: ignore[method-assign]
117 ctrl._managed_pool = Mock()
118 ctrl._managed_pool.fill = AsyncMock(
119 side_effect=lambda *_args, **_kwargs: [_track(item_id) for item_id in ALBUM_TRACKS]
120 )
121 ctrl._smart_shuffle = Mock()
122 ctrl._smart_shuffle.is_enabled = Mock(return_value=True)
123 ctrl._smart_shuffle.arrange = AsyncMock(side_effect=lambda _queue, items: list(items)[::-1])
124 ctrl._media_resolver = Mock()
125 ctrl._media_resolver._resolve_media_items = AsyncMock(
126 side_effect=lambda *_args, **_kwargs: [_track(item_id) for item_id in ALBUM_TRACKS]
127 )
128 queue = PlayerQueue(
129 queue_id="q1", active=True, display_name="Q1", available=True, items=0, **queue_kwargs
130 )
131 ctrl._queue_data = {"q1": PlayerQueueData(queue=queue)}
132 return ctrl
133
134
135def _queue(ctrl: Any) -> PlayerQueue:
136 """Return the controller's queue."""
137 return cast("PlayerQueue", ctrl._queue_data["q1"].queue)
138
139
140def _played_order(ctrl: Any) -> list[str]:
141 """Return the item ids of the tracks currently loaded in the queue, in play order."""
142 return [
143 item.media_item.item_id
144 for item in ctrl._queue_data["q1"].items
145 if item.media_item is not None
146 ]
147
148
149def _load_shuffled_queue(ctrl: Any) -> None:
150 """Fill the queue with items whose list order disagrees with their original sort order."""
151 items = []
152 for item_id, sort_index in SHUFFLED_QUEUE:
153 item = QueueItem.from_media_item("q1", _track(item_id))
154 item.sort_index = sort_index
155 items.append(item)
156 ctrl._queue_data["q1"].items = items
157 _queue(ctrl).items = len(items)
158
159
160def _load_dynamic_pool(ctrl: Any) -> None:
161 """Fill the queue with a dynamic queue's managed pool, its first item playing."""
162 ctrl._queue_data["q1"].items = [
163 QueueItem.from_media_item("q1", _track(item_id)) for item_id in POOL_TRACKS
164 ]
165 queue = _queue(ctrl)
166 queue.items = len(POOL_TRACKS)
167 queue.current_index = 0
168
169
170@pytest.mark.parametrize("option", START_OPTIONS)
171async def test_shuffle_left_on_by_previous_session_is_reset(option: QueueOption) -> None:
172 """An album started on a queue that still had shuffle on plays in its own track order."""
173 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True)
174
175 await ctrl.play_media("q1", _album(), option)
176
177 assert _queue(ctrl).shuffle_enabled is False
178 assert _queue(ctrl).smart_shuffle_active is False
179 # the album is played front to back, not in the (reversed) shuffle order
180 assert _played_order(ctrl) == ALBUM_TRACKS
181 # shuffle was settled before the items were resolved: a shuffled queue asks the resolver to
182 # keep the items preceding a chosen track, an in-order one does not
183 resolve_call = ctrl._media_resolver._resolve_media_items.call_args
184 assert resolve_call.kwargs["keep_preceding_items"] is False
185
186
187async def test_shuffle_left_on_is_reset_for_a_derived_enqueue_option() -> None:
188 """The reset also applies when the enqueue option comes from the media type's config default."""
189 ctrl = _controller(shuffle_enabled=True)
190
191 await ctrl.play_media("q1", _album())
192
193 # the option was derived from the album's configured default (which is 'replace')
194 assert ctrl.get_config_value.call_args.args[0] == "default_enqueue_option_album"
195 assert _queue(ctrl).shuffle_enabled is False
196 assert _played_order(ctrl) == ALBUM_TRACKS
197
198
199async def test_the_reset_restores_the_order_of_the_items_that_stay_in_the_queue() -> None:
200 """
201 Media played onto a shuffled queue puts the items it keeps back in their original order.
202
203 Unlike a replace, a play keeps the tail behind the current item, so the reset has to un-shuffle
204 that tail as well: items left in shuffled order behind a queue that now reads "shuffle off"
205 would contradict its own flag.
206 """
207 ctrl = _controller(shuffle_enabled=True, current_index=0)
208 _load_shuffled_queue(ctrl)
209
210 await ctrl.play_media("q1", _album(), QueueOption.PLAY)
211
212 assert _queue(ctrl).shuffle_enabled is False
213 # the item being played, then the album, then the kept tail back in its original order
214 assert _played_order(ctrl) == ["e1", *ALBUM_TRACKS, "e2", "e3", "e4", "e5"]
215
216
217async def test_shuffle_just_switched_on_by_the_user_is_honoured() -> None:
218 """Switching shuffle on and then pressing play shuffles the album the user picked."""
219 ctrl = _controller()
220 await ctrl.set_shuffle("q1", True)
221
222 await ctrl.play_media("q1", _album(), QueueOption.REPLACE)
223
224 assert _queue(ctrl).shuffle_enabled is True
225 assert _played_order(ctrl) == ALBUM_TRACKS[::-1]
226
227
228@pytest.mark.parametrize("option", START_OPTIONS)
229async def test_shuffle_intent_is_only_good_for_one_play(option: QueueOption) -> None:
230 """The toggle carries into the album the user starts next, but not into the one after it."""
231 ctrl = _controller()
232 await ctrl.set_shuffle("q1", True)
233 await ctrl.play_media("q1", _album(), option)
234
235 await ctrl.play_media("q1", _album(), option)
236
237 assert ctrl._queue_data["q1"].shuffle_set_at is None
238 assert _queue(ctrl).shuffle_enabled is False
239 assert _played_order(ctrl) == ALBUM_TRACKS
240
241
242async def test_switching_shuffle_off_drops_the_intent() -> None:
243 """Turning shuffle back off leaves no intent behind for the next play to pick up."""
244 ctrl = _controller()
245 await ctrl.set_shuffle("q1", True)
246 await ctrl.set_shuffle("q1", False)
247
248 await ctrl.play_media("q1", _album(), QueueOption.REPLACE)
249
250 assert ctrl._queue_data["q1"].shuffle_set_at is None
251 assert _queue(ctrl).shuffle_enabled is False
252 assert _played_order(ctrl) == ALBUM_TRACKS
253
254
255@pytest.mark.parametrize("option", START_OPTIONS)
256async def test_stale_shuffle_intent_is_not_honoured(option: QueueOption) -> None:
257 """A toggle from well before the play command is a leftover, not intent for this album."""
258 ctrl = _controller(shuffle_enabled=True)
259 ctrl._queue_data["q1"].shuffle_set_at = time.monotonic() - SHUFFLE_INTENT_WINDOW - 100
260
261 await ctrl.play_media("q1", _album(), option)
262
263 assert _queue(ctrl).shuffle_enabled is False
264 assert _played_order(ctrl) == ALBUM_TRACKS
265
266
267async def test_explicit_shuffle_request_shuffles_an_unshuffled_queue() -> None:
268 """A caller asking for a shuffled play gets one, without having to toggle shuffle first."""
269 ctrl = _controller()
270
271 await ctrl.play_media("q1", _album(), QueueOption.REPLACE, shuffle=True)
272
273 assert _queue(ctrl).shuffle_enabled is True
274 assert _played_order(ctrl) == ALBUM_TRACKS[::-1]
275
276
277@pytest.mark.parametrize("option", START_OPTIONS)
278async def test_explicit_no_shuffle_wins_over_a_fresh_intent(option: QueueOption) -> None:
279 """An explicit "play in order" beats the shuffle the user switched on moments earlier."""
280 ctrl = _controller()
281 await ctrl.set_shuffle("q1", True)
282
283 await ctrl.play_media("q1", _album(), option, shuffle=False)
284
285 assert _queue(ctrl).shuffle_enabled is False
286 assert _played_order(ctrl) == ALBUM_TRACKS
287
288
289async def test_dynamic_source_overrides_an_explicit_play_in_order() -> None:
290 """A dynamic source is an always-on smart mix, so it outranks an explicit "play in order"."""
291 ctrl = _controller()
292
293 await ctrl.play_media("q1", _dynamic_playlist(), QueueOption.REPLACE, shuffle=False)
294
295 assert _queue(ctrl).is_dynamic is True
296 assert _queue(ctrl).shuffle_enabled is True
297 assert _queue(ctrl).smart_shuffle_active is True
298
299
300async def test_replacing_a_dynamic_queue_drops_the_smart_mix_indicator() -> None:
301 """
302 An album started over a dynamic queue is a plain queue, so it must not report a smart mix.
303
304 The dynamic source is what made smart shuffle active here (the per-queue setting is off), so
305 dropping it has to take the indicator with it.
306 """
307 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
308 ctrl._smart_shuffle.is_enabled = Mock(return_value=False)
309
310 await ctrl.play_media("q1", _album(), QueueOption.REPLACE, shuffle=True)
311
312 assert _queue(ctrl).is_dynamic is False
313 # the caller asked for a shuffled play, but a plain random shuffle is not a smart mix
314 assert _queue(ctrl).shuffle_enabled is True
315 assert _queue(ctrl).smart_shuffle_active is False
316
317
318async def test_playing_over_a_dynamic_queue_honours_an_explicit_play_in_order() -> None:
319 """
320 An album played over a dynamic queue takes over from it, so it plays in the order asked for.
321
322 Play does not clear the queue up front, so the queue is still dynamic when the shuffle state is
323 settled - and a dynamic queue's toggle is locked, so the request cannot be routed through
324 set_shuffle. The requested state still has to reach the items, which are resolved against it.
325 """
326 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
327
328 await ctrl.play_media("q1", _album(), QueueOption.PLAY, shuffle=False)
329
330 # the album took over as the queue's only source, so the queue is a plain one again
331 assert _queue(ctrl).is_dynamic is False
332 assert _queue(ctrl).shuffle_enabled is False
333 assert _queue(ctrl).smart_shuffle_active is False
334 # the flag alone proves little here: the album itself has to come out in its own order
335 assert _played_order(ctrl) == ALBUM_TRACKS
336
337
338async def test_replace_next_over_a_dynamic_queue_drops_the_imposed_shuffle() -> None:
339 """
340 An album staged over a dynamic queue takes its source away, so the smart mix's shuffle goes too.
341
342 Staging normally leaves the shuffle state alone, but the shuffle a dynamic queue runs on is not
343 the user's own choice - its toggle is locked - so it must not silently reorder the album that
344 replaces it. Replace next is the only staging option that replaces the queue's sources.
345 """
346 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
347 _load_dynamic_pool(ctrl)
348
349 await ctrl.play_media("q1", _album(), QueueOption.REPLACE_NEXT)
350
351 assert _queue(ctrl).is_dynamic is False
352 assert _queue(ctrl).shuffle_enabled is False
353 assert _queue(ctrl).smart_shuffle_active is False
354 # the item playing, then the album in its own track order rather than the (reversed) shuffle
355 assert _played_order(ctrl) == ["p1", *ALBUM_TRACKS]
356 # settled before the items were resolved: a shuffled queue asks the resolver to keep the items
357 # preceding a chosen track, an in-order one does not
358 resolve_call = ctrl._media_resolver._resolve_media_items.call_args
359 assert resolve_call.kwargs["keep_preceding_items"] is False
360
361
362async def test_replace_next_over_a_dynamic_queue_drops_the_shuffle_intent_with_it() -> None:
363 """
364 The shuffle dropped here takes its intent along, so a later play does not resurrect it.
365
366 Staging a dynamic source keeps an intent the user left on the queue moments earlier. Once
367 replace next switches the imposed shuffle off, that stamp would otherwise still be fresh enough
368 for the next play to act on - shuffling media on a queue whose toggle now reads off.
369 """
370 ctrl = _controller()
371 await ctrl.set_shuffle("q1", True)
372 await ctrl.play_media("q1", _dynamic_playlist(), QueueOption.ADD)
373 assert ctrl._queue_data["q1"].shuffle_set_at is not None
374
375 await ctrl.play_media("q1", _album(), QueueOption.REPLACE_NEXT)
376
377 assert _queue(ctrl).shuffle_enabled is False
378 assert ctrl._queue_data["q1"].shuffle_set_at is None
379 # the next media the user starts plays in order, matching what the queue's toggle now says
380 await ctrl.play_media("q1", _album(), QueueOption.REPLACE)
381 assert _played_order(ctrl) == ALBUM_TRACKS
382
383
384async def test_replace_next_that_leaves_the_queue_dynamic_keeps_the_smart_mix() -> None:
385 """Staging another dynamic source keeps the queue an always-on smart mix, shuffle included."""
386 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True, is_dynamic=True)
387 _load_dynamic_pool(ctrl)
388
389 await ctrl.play_media("q1", _dynamic_playlist(), QueueOption.REPLACE_NEXT)
390
391 assert _queue(ctrl).is_dynamic is True
392 assert _queue(ctrl).shuffle_enabled is True
393 assert _queue(ctrl).smart_shuffle_active is True
394
395
396async def test_playing_media_on_an_ended_queue_resets_shuffle() -> None:
397 """A finished queue is started over by a play, so the shuffle goes with the old session."""
398 ctrl = _controller(shuffle_enabled=True, ended=True)
399
400 await ctrl.play_media("q1", _album(), QueueOption.PLAY)
401
402 assert _queue(ctrl).shuffle_enabled is False
403 assert _played_order(ctrl) == ALBUM_TRACKS
404
405
406@pytest.mark.parametrize("option", STAGE_OPTIONS)
407async def test_staging_media_onto_an_ended_queue_keeps_shuffle(option: QueueOption) -> None:
408 """
409 Staging media onto a finished queue keeps its shuffle, even though the queue restarts.
410
411 This is deliberate, not an oversight: the shuffle is only reset for the options the user reaches
412 for to start something now. Whether the queue happens to have played to its end does not change
413 what "queue this up" means, so it must not change the shuffle state either.
414 """
415 ctrl = _controller(shuffle_enabled=True, ended=True)
416 # a marker rather than a realistic timestamp: it only has to show the intent was left alone
417 ctrl._queue_data["q1"].shuffle_set_at = 12345.0
418
419 await ctrl.play_media("q1", _album(), option)
420
421 assert _queue(ctrl).shuffle_enabled is True
422 assert ctrl._queue_data["q1"].shuffle_set_at == 12345.0
423 # the batch landed in shuffle order instead of the album's own track order
424 assert sorted(_played_order(ctrl)) == sorted(ALBUM_TRACKS)
425 assert _played_order(ctrl) != ALBUM_TRACKS
426
427
428@pytest.mark.parametrize("shuffle_enabled", [True, False])
429@pytest.mark.parametrize("option", STAGE_OPTIONS)
430async def test_enqueueing_leaves_shuffle_untouched(
431 option: QueueOption, shuffle_enabled: bool
432) -> None:
433 """
434 Staging media onto the queue is not a new listening session, so it keeps its shuffle.
435
436 These all keep (part of) the existing queue, whose items are already in shuffled order, so
437 switching shuffle off here would leave those items contradicting the queue's own flag.
438 """
439 # a running queue, as opposed to the finished one the staging options start over from
440 ctrl = _controller(shuffle_enabled=shuffle_enabled, ended=False)
441 # a marker rather than a realistic timestamp: it only has to show the intent was left alone
442 ctrl._queue_data["q1"].shuffle_set_at = 12345.0
443
444 await ctrl.play_media("q1", _album(), option)
445
446 assert _queue(ctrl).shuffle_enabled is shuffle_enabled
447 # the intent is not consumed either: it still belongs to the next media the user starts
448 assert ctrl._queue_data["q1"].shuffle_set_at == 12345.0
449
450
451def test_clear_command_resets_shuffle() -> None:
452 """Clearing the queue is an explicit "start over", so the shuffle goes with the content."""
453 ctrl = _controller(shuffle_enabled=True, smart_shuffle_active=True)
454 ctrl._queue_data["q1"].items = [
455 QueueItem.from_media_item("q1", _track(item_id)) for item_id in ALBUM_TRACKS
456 ]
457
458 ctrl.clear("q1")
459
460 assert _queue(ctrl).shuffle_enabled is False
461 assert _queue(ctrl).smart_shuffle_active is False
462 assert ctrl._queue_data["q1"].shuffle_set_at is None
463
464
465def test_empty_queue_reaching_its_end_keeps_shuffle() -> None:
466 """An end reached with nothing to replay is still not the user clearing the queue."""
467 ctrl = _controller(shuffle_enabled=True)
468
469 ctrl.mark_ended("q1")
470
471 assert ctrl._queue_data["q1"].items == []
472 assert _queue(ctrl).shuffle_enabled is True
473