Couple bugs fixed. Shuffle shuffles from beginning.

This commit is contained in:
Storm Dragon
2025-12-16 03:54:17 -05:00
parent 1658321a4b
commit dd2d9a890a
3 changed files with 330 additions and 42 deletions
+142 -16
View File
@@ -2,6 +2,8 @@
Subsonic API client for Navidrome communication Subsonic API client for Navidrome communication
""" """
import hashlib
import json
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from urllib.parse import urlencode from urllib.parse import urlencode
import requests import requests
@@ -53,6 +55,27 @@ class SubsonicClient:
self.session.headers.update({ self.session.headers.update({
'User-Agent': f'{self.CLIENT_NAME}/1.0' 'User-Agent': f'{self.CLIENT_NAME}/1.0'
}) })
self._cache: Dict[str, Any] = {}
def _cacheKey(self, endpoint: str, params: Optional[dict] = None) -> str:
"""Generate a cache key from endpoint and parameters."""
keyData = {"endpoint": endpoint, "params": params or {}}
keyString = json.dumps(keyData, sort_keys=True)
return hashlib.md5(keyString.encode()).hexdigest()
def _getCached(self, endpoint: str, params: Optional[dict] = None) -> Optional[Any]:
"""Retrieve a cached response if available."""
key = self._cacheKey(endpoint, params)
return self._cache.get(key)
def _setCache(self, endpoint: str, params: Optional[dict], value: Any) -> None:
"""Store a response in the cache."""
key = self._cacheKey(endpoint, params)
self._cache[key] = value
def clearCache(self) -> None:
"""Clear all cached responses. Call this on library refresh."""
self._cache.clear()
def _makeRequest(self, endpoint: str, params: dict = None) -> dict: def _makeRequest(self, endpoint: str, params: dict = None) -> dict:
""" """
@@ -187,6 +210,10 @@ class SubsonicClient:
if musicFolderId: if musicFolderId:
params['musicFolderId'] = musicFolderId params['musicFolderId'] = musicFolderId
cached = self._getCached('getArtists', params)
if cached is not None:
return cached
response = self._makeRequest('getArtists', params) response = self._makeRequest('getArtists', params)
artists = [] artists = []
@@ -195,6 +222,7 @@ class SubsonicClient:
for artistData in index.get('artist', []): for artistData in index.get('artist', []):
artists.append(Artist.fromDict(artistData)) artists.append(Artist.fromDict(artistData))
self._setCache('getArtists', params, artists)
return artists return artists
def getArtist(self, artistId: str) -> Dict[str, Any]: def getArtist(self, artistId: str) -> Dict[str, Any]:
@@ -207,13 +235,20 @@ class SubsonicClient:
Returns: Returns:
dict with 'artist' (Artist) and 'albums' (List[Album]) dict with 'artist' (Artist) and 'albums' (List[Album])
""" """
response = self._makeRequest('getArtist', {'id': artistId}) params = {'id': artistId}
cached = self._getCached('getArtist', params)
if cached is not None:
return cached
response = self._makeRequest('getArtist', params)
artistData = response.get('artist', {}) artistData = response.get('artist', {})
artist = Artist.fromDict(artistData) artist = Artist.fromDict(artistData)
albums = [Album.fromDict(a) for a in artistData.get('album', [])] albums = [Album.fromDict(a) for a in artistData.get('album', [])]
return {'artist': artist, 'albums': albums} result = {'artist': artist, 'albums': albums}
self._setCache('getArtist', params, result)
return result
def getAlbum(self, albumId: str) -> Dict[str, Any]: def getAlbum(self, albumId: str) -> Dict[str, Any]:
""" """
@@ -225,13 +260,20 @@ class SubsonicClient:
Returns: Returns:
dict with 'album' (Album) and 'songs' (List[Song]) dict with 'album' (Album) and 'songs' (List[Song])
""" """
response = self._makeRequest('getAlbum', {'id': albumId}) params = {'id': albumId}
cached = self._getCached('getAlbum', params)
if cached is not None:
return cached
response = self._makeRequest('getAlbum', params)
albumData = response.get('album', {}) albumData = response.get('album', {})
album = Album.fromDict(albumData) album = Album.fromDict(albumData)
songs = [Song.fromDict(s) for s in albumData.get('song', [])] songs = [Song.fromDict(s) for s in albumData.get('song', [])]
return {'album': album, 'songs': songs} result = {'album': album, 'songs': songs}
self._setCache('getAlbum', params, result)
return result
def getSong(self, songId: str) -> Song: def getSong(self, songId: str) -> Song:
""" """
@@ -243,8 +285,15 @@ class SubsonicClient:
Returns: Returns:
Song object Song object
""" """
response = self._makeRequest('getSong', {'id': songId}) params = {'id': songId}
return Song.fromDict(response.get('song', {})) cached = self._getCached('getSong', params)
if cached is not None:
return cached
response = self._makeRequest('getSong', params)
result = Song.fromDict(response.get('song', {}))
self._setCache('getSong', params, result)
return result
def getGenres(self) -> List[Genre]: def getGenres(self) -> List[Genre]:
""" """
@@ -253,9 +302,15 @@ class SubsonicClient:
Returns: Returns:
List of Genre objects List of Genre objects
""" """
cached = self._getCached('getGenres', None)
if cached is not None:
return cached
response = self._makeRequest('getGenres') response = self._makeRequest('getGenres')
genresData = response.get('genres', {}) genresData = response.get('genres', {})
return [Genre.fromDict(g) for g in genresData.get('genre', [])] result = [Genre.fromDict(g) for g in genresData.get('genre', [])]
self._setCache('getGenres', None, result)
return result
def getAlbumList(self, listType: str, size: int = 20, offset: int = 0, def getAlbumList(self, listType: str, size: int = 20, offset: int = 0,
musicFolderId: str = None, genre: str = None, musicFolderId: str = None, genre: str = None,
@@ -289,9 +344,15 @@ class SubsonicClient:
if toYear: if toYear:
params['toYear'] = toYear params['toYear'] = toYear
cached = self._getCached('getAlbumList2', params)
if cached is not None:
return cached
response = self._makeRequest('getAlbumList2', params) response = self._makeRequest('getAlbumList2', params)
albumsData = response.get('albumList2', {}) albumsData = response.get('albumList2', {})
return [Album.fromDict(a) for a in albumsData.get('album', [])] result = [Album.fromDict(a) for a in albumsData.get('album', [])]
self._setCache('getAlbumList2', params, result)
return result
def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]: def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]:
""" """
@@ -310,9 +371,16 @@ class SubsonicClient:
'count': count, 'count': count,
'offset': offset 'offset': offset
} }
cached = self._getCached('getSongsByGenre', params)
if cached is not None:
return cached
response = self._makeRequest('getSongsByGenre', params) response = self._makeRequest('getSongsByGenre', params)
songsData = response.get('songsByGenre', {}) songsData = response.get('songsByGenre', {})
return [Song.fromDict(s) for s in songsData.get('song', [])] result = [Song.fromDict(s) for s in songsData.get('song', [])]
self._setCache('getSongsByGenre', params, result)
return result
def getRandomSongs(self, size: int = 10, genre: str = None, def getRandomSongs(self, size: int = 10, genre: str = None,
fromYear: int = None, toYear: int = None) -> List[Song]: fromYear: int = None, toYear: int = None) -> List[Song]:
@@ -362,14 +430,21 @@ class SubsonicClient:
'albumCount': albumCount, 'albumCount': albumCount,
'songCount': songCount 'songCount': songCount
} }
cached = self._getCached('search3', params)
if cached is not None:
return cached
response = self._makeRequest('search3', params) response = self._makeRequest('search3', params)
searchResult = response.get('searchResult3', {}) searchResult = response.get('searchResult3', {})
return { result = {
'artists': [Artist.fromDict(a) for a in searchResult.get('artist', [])], 'artists': [Artist.fromDict(a) for a in searchResult.get('artist', [])],
'albums': [Album.fromDict(a) for a in searchResult.get('album', [])], 'albums': [Album.fromDict(a) for a in searchResult.get('album', [])],
'songs': [Song.fromDict(s) for s in searchResult.get('song', [])] 'songs': [Song.fromDict(s) for s in searchResult.get('song', [])]
} }
self._setCache('search3', params, result)
return result
# ==================== Playlists ==================== # ==================== Playlists ====================
@@ -380,9 +455,15 @@ class SubsonicClient:
Returns: Returns:
List of Playlist objects List of Playlist objects
""" """
cached = self._getCached('getPlaylists', None)
if cached is not None:
return cached
response = self._makeRequest('getPlaylists') response = self._makeRequest('getPlaylists')
playlistsData = response.get('playlists', {}) playlistsData = response.get('playlists', {})
return [Playlist.fromDict(p) for p in playlistsData.get('playlist', [])] result = [Playlist.fromDict(p) for p in playlistsData.get('playlist', [])]
self._setCache('getPlaylists', None, result)
return result
def getPlaylist(self, playlistId: str) -> Playlist: def getPlaylist(self, playlistId: str) -> Playlist:
""" """
@@ -394,8 +475,15 @@ class SubsonicClient:
Returns: Returns:
Playlist object with songs populated Playlist object with songs populated
""" """
response = self._makeRequest('getPlaylist', {'id': playlistId}) params = {'id': playlistId}
return Playlist.fromDict(response.get('playlist', {})) cached = self._getCached('getPlaylist', params)
if cached is not None:
return cached
response = self._makeRequest('getPlaylist', params)
result = Playlist.fromDict(response.get('playlist', {}))
self._setCache('getPlaylist', params, result)
return result
def createPlaylist(self, name: str, songIds: List[str] = None) -> Playlist: def createPlaylist(self, name: str, songIds: List[str] = None) -> Playlist:
""" """
@@ -413,6 +501,7 @@ class SubsonicClient:
params['songId'] = songIds params['songId'] = songIds
response = self._makeRequest('createPlaylist', params) response = self._makeRequest('createPlaylist', params)
self._invalidatePlaylistCache()
return Playlist.fromDict(response.get('playlist', {})) return Playlist.fromDict(response.get('playlist', {}))
def updatePlaylist(self, playlistId: str, name: str = None, def updatePlaylist(self, playlistId: str, name: str = None,
@@ -436,6 +525,7 @@ class SubsonicClient:
params['songIndexToRemove'] = songIndexesToRemove params['songIndexToRemove'] = songIndexesToRemove
self._makeRequest('updatePlaylist', params) self._makeRequest('updatePlaylist', params)
self._invalidatePlaylistCache(playlistId)
def deletePlaylist(self, playlistId: str) -> None: def deletePlaylist(self, playlistId: str) -> None:
""" """
@@ -445,6 +535,15 @@ class SubsonicClient:
playlistId: ID of the playlist to delete playlistId: ID of the playlist to delete
""" """
self._makeRequest('deletePlaylist', {'id': playlistId}) self._makeRequest('deletePlaylist', {'id': playlistId})
self._invalidatePlaylistCache(playlistId)
def _invalidatePlaylistCache(self, playlistId: str = None) -> None:
"""Invalidate playlist-related cache entries."""
listKey = self._cacheKey('getPlaylists', None)
self._cache.pop(listKey, None)
if playlistId:
detailKey = self._cacheKey('getPlaylist', {'id': playlistId})
self._cache.pop(detailKey, None)
# ==================== User Actions ==================== # ==================== User Actions ====================
@@ -463,6 +562,7 @@ class SubsonicClient:
}.get(itemType, 'id') }.get(itemType, 'id')
self._makeRequest('star', {paramName: itemId}) self._makeRequest('star', {paramName: itemId})
self._invalidateStarredCache()
def unstar(self, itemId: str, itemType: str = 'song') -> None: def unstar(self, itemId: str, itemType: str = 'song') -> None:
""" """
@@ -479,6 +579,12 @@ class SubsonicClient:
}.get(itemType, 'id') }.get(itemType, 'id')
self._makeRequest('unstar', {paramName: itemId}) self._makeRequest('unstar', {paramName: itemId})
self._invalidateStarredCache()
def _invalidateStarredCache(self) -> None:
"""Invalidate the starred/favorites cache."""
key = self._cacheKey('getStarred2', None)
self._cache.pop(key, None)
def setRating(self, itemId: str, rating: int) -> None: def setRating(self, itemId: str, rating: int) -> None:
""" """
@@ -545,14 +651,20 @@ class SubsonicClient:
Returns: Returns:
dict with 'artists', 'albums', 'songs' lists dict with 'artists', 'albums', 'songs' lists
""" """
cached = self._getCached('getStarred2', None)
if cached is not None:
return cached
response = self._makeRequest('getStarred2') response = self._makeRequest('getStarred2')
starredData = response.get('starred2', {}) starredData = response.get('starred2', {})
return { result = {
'artists': [Artist.fromDict(a) for a in starredData.get('artist', [])], 'artists': [Artist.fromDict(a) for a in starredData.get('artist', [])],
'albums': [Album.fromDict(a) for a in starredData.get('album', [])], 'albums': [Album.fromDict(a) for a in starredData.get('album', [])],
'songs': [Song.fromDict(s) for s in starredData.get('song', [])] 'songs': [Song.fromDict(s) for s in starredData.get('song', [])]
} }
self._setCache('getStarred2', None, result)
return result
# ==================== Recommendations ==================== # ==================== Recommendations ====================
@@ -570,9 +682,16 @@ class SubsonicClient:
if not artist: if not artist:
return [] return []
params = {'artist': artist, 'count': count} params = {'artist': artist, 'count': count}
cached = self._getCached('getTopSongs', params)
if cached is not None:
return cached
response = self._makeRequest('getTopSongs', params) response = self._makeRequest('getTopSongs', params)
topData = response.get('topSongs', {}) topData = response.get('topSongs', {})
return [Song.fromDict(s) for s in topData.get('song', [])] result = [Song.fromDict(s) for s in topData.get('song', [])]
self._setCache('getTopSongs', params, result)
return result
def getSimilarSongs(self, songId: str, count: int = 50) -> List[Song]: def getSimilarSongs(self, songId: str, count: int = 50) -> List[Song]:
""" """
@@ -585,9 +704,16 @@ class SubsonicClient:
if not songId: if not songId:
return [] return []
params = {'id': songId, 'count': count} params = {'id': songId, 'count': count}
cached = self._getCached('getSimilarSongs2', params)
if cached is not None:
return cached
response = self._makeRequest('getSimilarSongs2', params) response = self._makeRequest('getSimilarSongs2', params)
data = response.get('similarSongs2', {}) data = response.get('similarSongs2', {})
return [Song.fromDict(s) for s in data.get('song', [])] result = [Song.fromDict(s) for s in data.get('song', [])]
self._setCache('getSimilarSongs2', params, result)
return result
def getNowPlaying(self) -> List[Song]: def getNowPlaying(self) -> List[Song]:
""" """
+53 -6
View File
@@ -58,6 +58,7 @@ class MainWindow(QMainWindow):
self.playback = PlaybackManager(self) self.playback = PlaybackManager(self)
self.mpris: Optional[MprisService] = None self.mpris: Optional[MprisService] = None
self._executor = ThreadPoolExecutor(max_workers=4) self._executor = ThreadPoolExecutor(max_workers=4)
self._shutting_down = False
self.logger = logging.getLogger("navipy.main_window") self.logger = logging.getLogger("navipy.main_window")
self.maxBulkSongs: Optional[int] = None # Unlimited; use throttling instead self.maxBulkSongs: Optional[int] = None # Unlimited; use throttling instead
self.bulkRequestBatch = 5 self.bulkRequestBatch = 5
@@ -531,6 +532,9 @@ class MainWindow(QMainWindow):
if not self.client: if not self.client:
return return
# Clear cache to force fresh data from server
self.client.clearCache()
self.refreshAction.setEnabled(False) self.refreshAction.setEnabled(False)
self.searchAction.setEnabled(False) self.searchAction.setEnabled(False)
self.libraryTree.clear() self.libraryTree.clear()
@@ -1058,6 +1062,8 @@ class MainWindow(QMainWindow):
def collectSongsForItem(self, item: QTreeWidgetItem) -> Tuple[List[Song], str, bool]: def collectSongsForItem(self, item: QTreeWidgetItem) -> Tuple[List[Song], str, bool]:
"""Resolve a tree item into a list of songs for queueing or playlist actions.""" """Resolve a tree item into a list of songs for queueing or playlist actions."""
if self._shutting_down:
return [], "", False
data: Dict = item.data(0, Qt.UserRole) or {} data: Dict = item.data(0, Qt.UserRole) or {}
itemType = data.get("type") itemType = data.get("type")
label = item.text(0) label = item.text(0)
@@ -1077,11 +1083,15 @@ class MainWindow(QMainWindow):
songs: List[Song] = albumData.get("songs", []) songs: List[Song] = albumData.get("songs", [])
return songs, (album.name if album else label), truncated return songs, (album.name if album else label), truncated
if itemType == "artist": if itemType == "artist":
if self._shutting_down:
return [], label, truncated
artistData = self.client.getArtist(data.get("id")) artistData = self.client.getArtist(data.get("id"))
artist: Optional[Artist] = artistData.get("artist") artist: Optional[Artist] = artistData.get("artist")
albums: List[Album] = artistData.get("albums", []) albums: List[Album] = artistData.get("albums", [])
songs: List[Song] = [] songs: List[Song] = []
for album in albums: for album in albums:
if self._shutting_down:
break
albumData = self.client.getAlbum(album.id) albumData = self.client.getAlbum(album.id)
songs.extend(albumData.get("songs", [])) songs.extend(albumData.get("songs", []))
request_count += 1 request_count += 1
@@ -1095,11 +1105,17 @@ class MainWindow(QMainWindow):
songs = self.client.getSongsByGenre(genreName) songs = self.client.getSongsByGenre(genreName)
return songs, (genreName or label), truncated return songs, (genreName or label), truncated
if itemType == "artists_root": if itemType == "artists_root":
if self._shutting_down:
return [], "all artists", truncated
artists: List[Artist] = self.client.getArtists() artists: List[Artist] = self.client.getArtists()
songs: List[Song] = [] songs: List[Song] = []
for artist in artists: for artist in artists:
if self._shutting_down:
break
artistData = self.client.getArtist(artist.id) artistData = self.client.getArtist(artist.id)
for album in artistData.get("albums", []): for album in artistData.get("albums", []):
if self._shutting_down:
break
albumData = self.client.getAlbum(album.id) albumData = self.client.getAlbum(album.id)
songs.extend(albumData.get("songs", [])) songs.extend(albumData.get("songs", []))
request_count += 1 request_count += 1
@@ -1359,12 +1375,16 @@ class MainWindow(QMainWindow):
def _fetchArtistSongs(self, artistId: str) -> Tuple[Optional[Artist], List[Song]]: def _fetchArtistSongs(self, artistId: str) -> Tuple[Optional[Artist], List[Song]]:
"""Fetch all songs for an artist across albums with throttling.""" """Fetch all songs for an artist across albums with throttling."""
if self._shutting_down:
return None, []
artistData = self.client.getArtist(artistId) artistData = self.client.getArtist(artistId)
artist: Optional[Artist] = artistData.get("artist") artist: Optional[Artist] = artistData.get("artist")
albums: List[Album] = artistData.get("albums", []) albums: List[Album] = artistData.get("albums", [])
songs: List[Song] = [] songs: List[Song] = []
request_count = 0 request_count = 0
for album in albums: for album in albums:
if self._shutting_down:
break
albumData = self.client.getAlbum(album.id) albumData = self.client.getAlbum(album.id)
songs.extend(albumData.get("songs", [])) songs.extend(albumData.get("songs", []))
request_count += 1 request_count += 1
@@ -1381,6 +1401,8 @@ class MainWindow(QMainWindow):
def _progressiveLoadAllArtists(self) -> int: def _progressiveLoadAllArtists(self) -> int:
"""Fetch songs across all artists in chunks and enqueue progressively.""" """Fetch songs across all artists in chunks and enqueue progressively."""
if self._shutting_down:
return 0
artists: List[Artist] = self.client.getArtists() artists: List[Artist] = self.client.getArtists()
batch: List[Song] = [] batch: List[Song] = []
request_count = 0 request_count = 0
@@ -1388,8 +1410,12 @@ class MainWindow(QMainWindow):
first_batch = True first_batch = True
for artist in artists: for artist in artists:
if self._shutting_down:
break
artistData = self.client.getArtist(artist.id) artistData = self.client.getArtist(artist.id)
for album in artistData.get("albums", []): for album in artistData.get("albums", []):
if self._shutting_down:
break
albumData = self.client.getAlbum(album.id) albumData = self.client.getAlbum(album.id)
songs = albumData.get("songs", []) songs = albumData.get("songs", [])
batch.extend(songs) batch.extend(songs)
@@ -1401,7 +1427,7 @@ class MainWindow(QMainWindow):
first_batch = False first_batch = False
self._throttle_requests(request_count) self._throttle_requests(request_count)
if batch: if batch and not self._shutting_down:
self._on_ui(lambda b=batch.copy(), first=first_batch: self._enqueueBatchForPlayback(b, "all artists", first)) self._on_ui(lambda b=batch.copy(), first=first_batch: self._enqueueBatchForPlayback(b, "all artists", first))
return total return total
@@ -1767,9 +1793,6 @@ class MainWindow(QMainWindow):
except Exception: except Exception:
# As a last resort, change accessible name to trigger a change event # As a last resort, change accessible name to trigger a change event
self.liveRegion.setAccessibleName(text) self.liveRegion.setAccessibleName(text)
except Exception:
# If accessibility event fails, rely on status bar message
pass
# Shortcut handlers with feedback when no playback is active # Shortcut handlers with feedback when no playback is active
@@ -1912,11 +1935,35 @@ class MainWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
"""Handle window close""" """Handle window close"""
# Signal background tasks to stop
self._shutting_down = True
self.settings.set("playback", "volume", self.volume) self.settings.set("playback", "volume", self.volume)
self.settings.save() self.settings.save()
# Stop playback and release media resources
try:
self.playback.player.stop()
self.playback.player.setSource("")
except Exception:
pass
# Shutdown MPRIS (stops GLib main loop thread)
if self.mpris: if self.mpris:
self.mpris.shutdown() try:
self.mpris.shutdown()
except Exception:
pass
# Shutdown thread pool - don't wait for tasks
if self._executor: if self._executor:
self._executor.shutdown(wait=False) self._executor.shutdown(wait=False, cancel_futures=True)
self.logger.info("Main window closed") self.logger.info("Main window closed")
event.accept() event.accept()
# Schedule forced exit after event loop processes the close
def forceExit():
import os
os._exit(0)
QTimer.singleShot(100, forceExit)
+135 -20
View File
@@ -5,7 +5,7 @@ Playback manager using PySide6 multimedia for streaming audio
from __future__ import annotations from __future__ import annotations
from typing import Callable, List, Optional from typing import Callable, List, Optional
from random import randint import random
from PySide6.QtCore import QObject, QUrl, Signal from PySide6.QtCore import QObject, QUrl, Signal
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
@@ -38,6 +38,11 @@ class PlaybackManager(QObject):
self.shuffleEnabled = False self.shuffleEnabled = False
self.repeatMode = "none" # none, one, all self.repeatMode = "none" # none, one, all
# Shuffle state: shuffleOrder is a list of queue indices in shuffled order
# shufflePosition tracks where we are in that order
self._shuffleOrder: List[int] = []
self._shufflePosition: int = -1
self.player.playbackStateChanged.connect(self._handlePlaybackStateChanged) self.player.playbackStateChanged.connect(self._handlePlaybackStateChanged)
self.player.positionChanged.connect(self._handlePositionChanged) self.player.positionChanged.connect(self._handlePositionChanged)
self.player.mediaStatusChanged.connect(self._handleMediaStatus) self.player.mediaStatusChanged.connect(self._handleMediaStatus)
@@ -54,10 +59,12 @@ class PlaybackManager(QObject):
def enqueue(self, song: Song, playNow: bool = False) -> None: def enqueue(self, song: Song, playNow: bool = False) -> None:
"""Add a song to the queue, optionally starting playback immediately.""" """Add a song to the queue, optionally starting playback immediately."""
newIndex = len(self.queue)
self.queue.append(song) self.queue.append(song)
self._addToShuffleOrder(newIndex)
self.queueChanged.emit(self.queue.copy()) self.queueChanged.emit(self.queue.copy())
if playNow or self.currentIndex == -1: if playNow or self.currentIndex == -1:
self.playIndex(len(self.queue) - 1) self.playIndex(newIndex)
def enqueueMany(self, songs: List[Song], playFirst: bool = False) -> None: def enqueueMany(self, songs: List[Song], playFirst: bool = False) -> None:
"""Add multiple songs to the queue.""" """Add multiple songs to the queue."""
@@ -65,14 +72,25 @@ class PlaybackManager(QObject):
return return
startIndex = len(self.queue) startIndex = len(self.queue)
self.queue.extend(songs) self.queue.extend(songs)
# Add new indices to shuffle order
for i in range(startIndex, len(self.queue)):
self._addToShuffleOrder(i)
self.queueChanged.emit(self.queue.copy()) self.queueChanged.emit(self.queue.copy())
if playFirst or self.currentIndex == -1: if playFirst or self.currentIndex == -1:
self.playIndex(startIndex) # When starting fresh playback with shuffle, pick a random song
if self.shuffleEnabled and self.currentIndex == -1:
self._shufflePosition = 0
targetIndex = self._shuffleOrder[0] if self._shuffleOrder else startIndex
self.playIndex(targetIndex)
else:
self.playIndex(startIndex)
def clearQueue(self) -> None: def clearQueue(self) -> None:
"""Clear the play queue and stop playback.""" """Clear the play queue and stop playback."""
self.queue = [] self.queue = []
self.currentIndex = -1 self.currentIndex = -1
self._shuffleOrder = []
self._shufflePosition = -1
self.player.stop() self.player.stop()
self.queueChanged.emit(self.queue.copy()) self.queueChanged.emit(self.queue.copy())
self.playbackStateChanged.emit("stopped") self.playbackStateChanged.emit("stopped")
@@ -86,6 +104,8 @@ class PlaybackManager(QObject):
return return
self.currentIndex = index self.currentIndex = index
# Sync shuffle position to match the played index
self._syncShufflePosition(index)
song = self.queue[index] song = self.queue[index]
streamUrl = self.streamResolver(song) streamUrl = self.streamResolver(song)
self.player.setSource(QUrl(streamUrl)) self.player.setSource(QUrl(streamUrl))
@@ -102,7 +122,7 @@ class PlaybackManager(QObject):
self.player.play() self.player.play()
else: else:
if self.currentIndex == -1 and self.queue: if self.currentIndex == -1 and self.queue:
self.playIndex(0) self._startPlayback()
elif self.currentIndex != -1: elif self.currentIndex != -1:
self.player.play() self.player.play()
@@ -115,7 +135,7 @@ class PlaybackManager(QObject):
self.player.play() self.player.play()
return return
if self.currentIndex == -1 and self.queue: if self.currentIndex == -1 and self.queue:
self.playIndex(0) self._startPlayback()
elif self.currentIndex != -1: elif self.currentIndex != -1:
self.player.play() self.player.play()
@@ -133,17 +153,27 @@ class PlaybackManager(QObject):
self.playIndex(self.currentIndex) self.playIndex(self.currentIndex)
return return
if self.shuffleEnabled: if self.shuffleEnabled and self._shuffleOrder:
target = randint(0, len(self.queue) - 1) # Move to next position in shuffle order
nextPosition = self._shufflePosition + 1
if nextPosition >= len(self._shuffleOrder):
if self.repeatMode == "all":
# Reshuffle for the next cycle
self._regenerateShuffleOrder()
nextPosition = 0
else:
self.player.stop()
return
self._shufflePosition = nextPosition
target = self._shuffleOrder[nextPosition]
else: else:
target = self.currentIndex + 1 target = self.currentIndex + 1
if target >= len(self.queue):
if target >= len(self.queue): if self.repeatMode == "all":
if self.repeatMode == "all": target = 0
target = 0 else:
else: self.player.stop()
self.player.stop() return
return
self.playIndex(target) self.playIndex(target)
@@ -151,12 +181,25 @@ class PlaybackManager(QObject):
"""Return to the previous track.""" """Return to the previous track."""
if not self.queue: if not self.queue:
return return
target = self.currentIndex - 1
if target < 0: if self.shuffleEnabled and self._shuffleOrder:
if self.repeatMode == "all": # Move to previous position in shuffle order
target = len(self.queue) - 1 prevPosition = self._shufflePosition - 1
else: if prevPosition < 0:
target = 0 if self.repeatMode == "all":
prevPosition = len(self._shuffleOrder) - 1
else:
prevPosition = 0
self._shufflePosition = prevPosition
target = self._shuffleOrder[prevPosition]
else:
target = self.currentIndex - 1
if target < 0:
if self.repeatMode == "all":
target = len(self.queue) - 1
else:
target = 0
self.playIndex(target) self.playIndex(target)
def seek(self, positionMs: int) -> None: def seek(self, positionMs: int) -> None:
@@ -165,7 +208,15 @@ class PlaybackManager(QObject):
def setShuffle(self, enabled: bool) -> None: def setShuffle(self, enabled: bool) -> None:
"""Enable or disable shuffle mode.""" """Enable or disable shuffle mode."""
wasEnabled = self.shuffleEnabled
self.shuffleEnabled = enabled self.shuffleEnabled = enabled
if enabled and not wasEnabled:
# Just turned on shuffle - regenerate order with current song first
self._regenerateShuffleOrder(startWithCurrent=True)
elif not enabled and wasEnabled:
# Turned off shuffle - clear shuffle state
self._shuffleOrder = []
self._shufflePosition = -1
def setRepeatMode(self, mode: str) -> None: def setRepeatMode(self, mode: str) -> None:
"""Set repeat mode: none, one, or all.""" """Set repeat mode: none, one, or all."""
@@ -178,6 +229,70 @@ class PlaybackManager(QObject):
return self.queue[self.currentIndex] return self.queue[self.currentIndex]
return None return None
# ===== Shuffle helpers =====
def _regenerateShuffleOrder(self, startWithCurrent: bool = False) -> None:
"""Create a new shuffled order of queue indices."""
if not self.queue:
self._shuffleOrder = []
self._shufflePosition = -1
return
indices = list(range(len(self.queue)))
random.shuffle(indices)
if startWithCurrent and self.currentIndex >= 0:
# Put current song at the front so we continue from here
if self.currentIndex in indices:
indices.remove(self.currentIndex)
indices.insert(0, self.currentIndex)
self._shufflePosition = 0
else:
self._shufflePosition = 0
self._shuffleOrder = indices
def _addToShuffleOrder(self, index: int) -> None:
"""Add a new queue index to the shuffle order at a random position."""
if not self.shuffleEnabled:
return
if not self._shuffleOrder:
self._shuffleOrder = [index]
return
# Insert at a random position after the current position
# so we don't replay songs we've already heard
if self._shufflePosition < 0:
insertPos = random.randint(0, len(self._shuffleOrder))
else:
# Insert somewhere after current position
insertPos = random.randint(self._shufflePosition + 1, len(self._shuffleOrder))
self._shuffleOrder.insert(insertPos, index)
def _syncShufflePosition(self, queueIndex: int) -> None:
"""Update shuffle position to match a directly-selected queue index."""
if not self.shuffleEnabled or not self._shuffleOrder:
return
try:
self._shufflePosition = self._shuffleOrder.index(queueIndex)
except ValueError:
# Index not in shuffle order - add it and set position
self._shuffleOrder.append(queueIndex)
self._shufflePosition = len(self._shuffleOrder) - 1
def _startPlayback(self) -> None:
"""Start playback, respecting shuffle mode for initial song selection."""
if not self.queue:
return
if self.shuffleEnabled:
self._regenerateShuffleOrder()
if self._shuffleOrder:
self._shufflePosition = 0
self.playIndex(self._shuffleOrder[0])
else:
self.playIndex(0)
else:
self.playIndex(0)
# Signal handlers # Signal handlers
def _handlePlaybackStateChanged(self, state) -> None: def _handlePlaybackStateChanged(self, state) -> None: