From a304b43a348684687efe068054d312dfebf4ab25 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sun, 14 Dec 2025 16:16:24 +0100 Subject: [PATCH] Make the shuffle a bit smarter --- music_assistant/controllers/player_queues.py | 97 +++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 60f436b8..a843709b 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -1326,7 +1326,7 @@ class PlayerQueuesController(CoreController): item.sort_index += insert_at_index + index # (re)shuffle the final batch if needed if shuffle: - next_items = random.sample(next_items, len(next_items)) + next_items = _smart_shuffle(next_items) self.update_items(queue_id, prev_items + next_items) def update_items(self, queue_id: str, queue_items: list[QueueItem]) -> None: @@ -2383,3 +2383,98 @@ class PlayerQueuesController(CoreController): userid=queue.userid, ), ) + + +def _smart_shuffle(items: list[QueueItem]) -> list[QueueItem]: + """Shuffle queue items with smart spacing rules. + + This shuffle tries to prevent the same track and artist from appearing + too close together. Spacing requirements scale with playlist size: + - >1000 items: track spacing 15, artist spacing 10 + - >500 items: track spacing 10, artist spacing 6 + - >100 items: track spacing 5, artist spacing 3 + - <=100 items: track spacing 2, no artist spacing + + This is a best-effort approach - when playing an album where all tracks + are from the same artist, artist spacing won't be possible. + + :param items: List of queue items to shuffle. + """ + if len(items) <= 1: + return items + + # Determine spacing based on playlist size + num_items = len(items) + if num_items > 1000: + track_spacing, artist_spacing = 15, 10 + elif num_items > 500: + track_spacing, artist_spacing = 10, 6 + elif num_items > 100: + track_spacing, artist_spacing = 5, 3 + else: + track_spacing, artist_spacing = 2, 0 + + # Extract artist from name format " - " + def get_artist(name: str) -> str | None: + return name.split(" - ", 1)[0] if " - " in name else None + + # Start with a random shuffle + shuffled = random.sample(items, len(items)) + + # Iteratively fix violations + max_attempts = len(items) * 3 + for _ in range(max_attempts): + violation_found = False + + for i in range(1, len(shuffled)): + current = shuffled[i] + current_artist = get_artist(current.name) + + # Check for track collision + has_violation = any( + shuffled[j].name == current.name for j in range(max(0, i - track_spacing), i) + ) + + # Check for artist collision (only if artist_spacing > 0) + if not has_violation and artist_spacing and current_artist: + has_violation = any( + get_artist(shuffled[j].name) == current_artist + for j in range(max(0, i - artist_spacing), i) + ) + + if has_violation: + violation_found = True + # Find best position after current by scoring distance from conflicts + best_pos, best_score = i, -1 + for pos in range(i + 1, len(shuffled)): + track_dist = min( + ( + pos - j + for j in range(max(0, pos - track_spacing), pos) + if shuffled[j].name == current.name + ), + default=track_spacing, + ) + artist_dist = artist_spacing + if artist_spacing and current_artist: + artist_dist = min( + ( + pos - j + for j in range(max(0, pos - artist_spacing), pos) + if get_artist(shuffled[j].name) == current_artist + ), + default=artist_spacing, + ) + score = track_dist * 2 + artist_dist + if score > best_score: + best_score, best_pos = score, pos + + if best_pos != i: + item = shuffled.pop(i) + shuffled.insert(best_pos, item) + break + + if not violation_found: + break + + return shuffled -- 2.34.1