Skip to content

Commit fb8f035

Browse files
committed
Fix random shuffle performance hog
1 parent f28a116 commit fb8f035

5 files changed

Lines changed: 42 additions & 102 deletions

File tree

music_assistant/controllers/player_queues.py

Lines changed: 33 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ def get_active_queue(self, player_id: str) -> PlayerQueue | None:
303303
# Queue commands
304304

305305
@api_command("player_queues/shuffle")
306-
def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None:
306+
async def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None:
307307
"""Configure shuffle setting on the the queue."""
308308
queue = self._queues[queue_id]
309309
if queue.shuffle_enabled == shuffle_enabled:
@@ -320,7 +320,7 @@ def set_shuffle(self, queue_id: str, shuffle_enabled: bool) -> None:
320320
if not shuffle_enabled:
321321
# shuffle disabled, try to restore original sort order of the remaining items
322322
next_items.sort(key=lambda x: x.sort_index, reverse=False)
323-
self.load(
323+
await self.load(
324324
queue_id=queue_id,
325325
queue_items=next_items,
326326
insert_at_index=next_index,
@@ -517,7 +517,7 @@ async def play_media(
517517

518518
# handle replace: clear all items and replace with the new items
519519
if option == QueueOption.REPLACE:
520-
self.load(
520+
await self.load(
521521
queue_id,
522522
queue_items=queue_items,
523523
keep_remaining=False,
@@ -528,15 +528,15 @@ async def play_media(
528528
return
529529
# handle next: add item(s) in the index next to the playing/loaded/buffered index
530530
if option == QueueOption.NEXT:
531-
self.load(
531+
await self.load(
532532
queue_id,
533533
queue_items=queue_items,
534534
insert_at_index=insert_at_index,
535535
shuffle=shuffle,
536536
)
537537
return
538538
if option == QueueOption.REPLACE_NEXT:
539-
self.load(
539+
await self.load(
540540
queue_id,
541541
queue_items=queue_items,
542542
insert_at_index=insert_at_index,
@@ -546,7 +546,7 @@ async def play_media(
546546
return
547547
# handle play: replace current loaded/playing index with new item(s)
548548
if option == QueueOption.PLAY:
549-
self.load(
549+
await self.load(
550550
queue_id,
551551
queue_items=queue_items,
552552
insert_at_index=insert_at_index,
@@ -557,7 +557,7 @@ async def play_media(
557557
return
558558
# handle add: add/append item(s) to the remaining queue items
559559
if option == QueueOption.ADD:
560-
self.load(
560+
await self.load(
561561
queue_id=queue_id,
562562
queue_items=queue_items,
563563
insert_at_index=insert_at_index
@@ -1014,7 +1014,7 @@ async def transfer_queue(
10141014
target_queue.current_item.queue_id = target_queue_id
10151015
self.clear(source_queue_id)
10161016

1017-
self.load(target_queue_id, source_items, keep_remaining=False, keep_played=False)
1017+
await self.load(target_queue_id, source_items, keep_remaining=False, keep_played=False)
10181018
for item in source_items:
10191019
item.queue_id = target_queue_id
10201020
self.update_items(target_queue_id, source_items)
@@ -1302,7 +1302,7 @@ def track_loaded_in_buffer(self, queue_id: str, item_id: str) -> None:
13021302

13031303
# Main queue manipulation methods
13041304

1305-
def load(
1305+
async def load(
13061306
self,
13071307
queue_id: str,
13081308
queue_items: list[QueueItem],
@@ -1331,7 +1331,7 @@ def load(
13311331
item.sort_index += insert_at_index + index
13321332
# (re)shuffle the final batch if needed
13331333
if shuffle:
1334-
next_items = _smart_shuffle(next_items)
1334+
next_items = await _smart_shuffle(next_items)
13351335
self.update_items(queue_id, prev_items + next_items)
13361336

13371337
def update_items(self, queue_id: str, queue_items: list[QueueItem]) -> None:
@@ -1693,7 +1693,7 @@ async def _fill_radio_tracks(self, queue_id: str) -> None:
16931693
tracks = await self._get_radio_tracks(queue_id=queue_id, is_initial_radio_mode=False)
16941694
# fill queue - filter out unavailable items
16951695
queue_items = [QueueItem.from_media_item(queue_id, x) for x in tracks if x.available]
1696-
self.load(
1696+
await self.load(
16971697
queue_id,
16981698
queue_items,
16991699
insert_at_index=len(self._queue_items[queue_id]) + 1,
@@ -2476,96 +2476,36 @@ def _handle_playback_progress_report(
24762476
)
24772477

24782478

2479-
def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]:
2480-
"""Shuffle queue items with smart spacing rules.
2479+
async def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]:
2480+
"""Shuffle queue items, avoiding identical tracks next to each other.
24812481
2482-
This shuffle tries to prevent the same track and artist from appearing
2483-
too close together. Spacing requirements scale with playlist size:
2484-
- >1000 items: track spacing 15, artist spacing 10
2485-
- >500 items: track spacing 10, artist spacing 6
2486-
- >100 items: track spacing 5, artist spacing 3
2487-
- <=100 items: track spacing 2, no artist spacing
2488-
2489-
This is a best-effort approach - when playing an album where all tracks
2490-
are from the same artist, artist spacing won't be possible.
2482+
Best-effort approach to prevent the same track from appearing adjacent.
2483+
Does a random shuffle first, then makes a limited number of passes to
2484+
swap adjacent duplicates with a random item further in the list.
24912485
24922486
:param items: List of queue items to shuffle.
24932487
"""
2494-
if len(items) <= 1:
2495-
return items
2496-
2497-
# Determine spacing based on playlist size
2498-
num_items = len(items)
2499-
if num_items > 1000:
2500-
track_spacing, artist_spacing = 15, 10
2501-
elif num_items > 500:
2502-
track_spacing, artist_spacing = 10, 6
2503-
elif num_items > 100:
2504-
track_spacing, artist_spacing = 5, 3
2505-
else:
2506-
track_spacing, artist_spacing = 2, 0
2507-
2508-
# Extract artist from name format "<artist(s)> - <title>"
2509-
def get_artist(name: str) -> str | None:
2510-
return name.split(" - ", 1)[0] if " - " in name else None
2488+
if len(items) <= 2:
2489+
return random.sample(items, len(items)) if len(items) == 2 else items
25112490

25122491
# Start with a random shuffle
25132492
shuffled = random.sample(items, len(items))
25142493

2515-
# Iteratively fix violations
2516-
max_attempts = len(items) * 3
2517-
for _ in range(max_attempts):
2518-
violation_found = False
2519-
2520-
for i in range(1, len(shuffled)):
2521-
current = shuffled[i]
2522-
current_artist = get_artist(current.name)
2523-
2524-
# Check for track collision
2525-
has_violation = any(
2526-
shuffled[j].name == current.name for j in range(max(0, i - track_spacing), i)
2527-
)
2528-
2529-
# Check for artist collision (only if artist_spacing > 0)
2530-
if not has_violation and artist_spacing and current_artist:
2531-
has_violation = any(
2532-
get_artist(shuffled[j].name) == current_artist
2533-
for j in range(max(0, i - artist_spacing), i)
2534-
)
2535-
2536-
if has_violation:
2537-
violation_found = True
2538-
# Find best position after current by scoring distance from conflicts
2539-
best_pos, best_score = i, -1
2540-
for pos in range(i + 1, len(shuffled)):
2541-
track_dist = min(
2542-
(
2543-
pos - j
2544-
for j in range(max(0, pos - track_spacing), pos)
2545-
if shuffled[j].name == current.name
2546-
),
2547-
default=track_spacing,
2548-
)
2549-
artist_dist = artist_spacing
2550-
if artist_spacing and current_artist:
2551-
artist_dist = min(
2552-
(
2553-
pos - j
2554-
for j in range(max(0, pos - artist_spacing), pos)
2555-
if get_artist(shuffled[j].name) == current_artist
2556-
),
2557-
default=artist_spacing,
2558-
)
2559-
score = track_dist * 2 + artist_dist
2560-
if score > best_score:
2561-
best_score, best_pos = score, pos
2562-
2563-
if best_pos != i:
2564-
item = shuffled.pop(i)
2565-
shuffled.insert(best_pos, item)
2566-
break
2567-
2568-
if not violation_found:
2494+
# Make a few passes to fix adjacent duplicates
2495+
max_passes = 3
2496+
for _ in range(max_passes):
2497+
swapped = False
2498+
for i in range(len(shuffled) - 1):
2499+
if shuffled[i].name == shuffled[i + 1].name:
2500+
# Found adjacent duplicate - swap with random position at least 2 away
2501+
swap_candidates = [j for j in range(len(shuffled)) if abs(j - i - 1) >= 2]
2502+
if swap_candidates:
2503+
swap_pos = random.choice(swap_candidates)
2504+
shuffled[i + 1], shuffled[swap_pos] = shuffled[swap_pos], shuffled[i + 1]
2505+
swapped = True
2506+
if not swapped:
25692507
break
2508+
# Yield to event loop between passes
2509+
await asyncio.sleep(0)
25702510

25712511
return shuffled

music_assistant/providers/airplay/provider.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ async def _handle_dacp_request( # noqa: PLR0915
294294
queue = self.mass.player_queues.get(player_id)
295295
if not queue:
296296
return
297-
self.mass.player_queues.set_shuffle(
297+
await self.mass.player_queues.set_shuffle(
298298
active_queue.queue_id, not queue.shuffle_enabled
299299
)
300300
elif path in ("/ctrl-int/1/pause", "/ctrl-int/1/discrete-pause"):

music_assistant/providers/plex_connect/player_remote.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ async def handle_play_media(self, request: web.Request) -> web.Response:
429429

430430
# Set shuffle if requested
431431
if shuffle:
432-
self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
432+
await self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
433433

434434
# Seek to offset if specified
435435
if offset > 0:
@@ -591,7 +591,7 @@ def fetch_queue() -> PlayQueue:
591591

592592
# Apply shuffle if requested
593593
if shuffle:
594-
self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
594+
await self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
595595

596596
# Seek to offset if specified
597597
if offset > 0:
@@ -771,7 +771,7 @@ def fetch_queue() -> PlayQueue:
771771
return web.Response(status=500, text="No player assigned")
772772

773773
# disable shuffle to avoid infinite loop
774-
self.provider.mass.player_queues.set_shuffle(player_id, False)
774+
await self.provider.mass.player_queues.set_shuffle(player_id, False)
775775
ma_queue = self.provider.mass.player_queues.get(player_id)
776776
if not ma_queue:
777777
LOGGER.error(f"MA queue not found for player {player_id}")
@@ -904,7 +904,7 @@ def create_queue() -> PlayQueue:
904904

905905
# Apply shuffle if requested (Plex may have already shuffled server-side)
906906
if shuffle:
907-
self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
907+
await self.provider.mass.player_queues.set_shuffle(player_id, shuffle)
908908
else:
909909
LOGGER.error("No valid tracks in created play queue")
910910
return web.Response(status=500, text="Failed to load tracks from play queue")
@@ -1150,7 +1150,7 @@ async def handle_set_parameters(self, request: web.Request) -> web.Response:
11501150
if "shuffle" in request.query:
11511151
# Plex sends shuffle as "0" or "1"
11521152
shuffle = request.query["shuffle"] == "1"
1153-
self.provider.mass.player_queues.set_shuffle(self._ma_player_id, shuffle)
1153+
await self.provider.mass.player_queues.set_shuffle(self._ma_player_id, shuffle)
11541154

11551155
if "repeat" in request.query:
11561156
# Plex repeat: 0=off, 1=repeat one, 2=repeat all

music_assistant/providers/sendspin/player.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,9 @@ async def _handle_group_command(self, command: MediaCommand) -> None:
285285
case MediaCommand.REPEAT_ALL if queue:
286286
self.mass.player_queues.set_repeat(queue.queue_id, RepeatMode.ALL)
287287
case MediaCommand.SHUFFLE if queue:
288-
self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=True)
288+
await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=True)
289289
case MediaCommand.UNSHUFFLE if queue:
290-
self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False)
290+
await self.mass.player_queues.set_shuffle(queue.queue_id, shuffle_enabled=False)
291291

292292
async def group_event_cb(self, group: SendspinGroup, event: GroupEvent) -> None:
293293
"""Event callback registered to the sendspin group this player belongs to."""

music_assistant/providers/squeezelite/player.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ async def _handle_player_cli_event(self, event: SlimEvent) -> None:
528528
self.client.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
529529
self.client.signal_update()
530530
elif event.data == "button shuffle":
531-
self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
531+
await self.mass.player_queues.set_shuffle(queue.queue_id, not queue.shuffle_enabled)
532532
self.client.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
533533
self.client.signal_update()
534534
elif event_data in ("button jump_fwd", "button fwd"):

0 commit comments

Comments
 (0)