diff --git a/navipy.py b/navipy.py index 67c3711..3007b23 100755 --- a/navipy.py +++ b/navipy.py @@ -32,7 +32,7 @@ def setupLogging(): format='%(message)s [%(asctime)s]', datefmt='%a %b %d %I:%M:%S %p %Z %Y', handlers=[ - logging.FileHandler(logFile, encoding='utf-8'), + logging.FileHandler(logFile, mode='w', encoding='utf-8'), logging.StreamHandler(sys.stdout) ] ) diff --git a/src/main_window.py b/src/main_window.py index 27a13b0..8d12e68 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -3,8 +3,10 @@ from __future__ import annotations from pathlib import Path -from typing import Dict, List, Optional, Tuple - +from typing import Any, Callable, Dict, List, Optional, Tuple +import logging +import time +from concurrent.futures import ThreadPoolExecutor from PySide6.QtWidgets import ( QHBoxLayout, QLabel, @@ -20,7 +22,7 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) -from PySide6.QtCore import QPoint, Qt +from PySide6.QtCore import QPoint, Qt, QTimer from PySide6.QtGui import ( QAction, QAccessible, @@ -33,7 +35,7 @@ from PySide6.QtGui import ( from src.accessibility.accessible_tree import AccessibleTreeWidget from src.api.client import SubsonicClient, SubsonicError -from src.api.models import Album, Artist, Playlist, Song +from src.api.models import Album, Artist, Playlist, Song, Genre from src.config.settings import Settings from src.managers.playback_manager import PlaybackManager from src.integrations.mpris import MprisService @@ -55,6 +57,13 @@ class MainWindow(QMainWindow): self.client: Optional[SubsonicClient] = None self.playback = PlaybackManager(self) self.mpris: Optional[MprisService] = None + self._executor = ThreadPoolExecutor(max_workers=4) + self.logger = logging.getLogger("navipy.main_window") + self.maxBulkSongs: Optional[int] = None # Unlimited; use throttling instead + self.bulkRequestBatch = 5 + self.bulkThrottleSeconds = 0.1 + self.bulkChunkSize = 500 + self.logger.info("MainWindow initialized") self.pageStep = self.settings.get("interface", "pageStep", 5) self.volume = self.settings.get("playback", "volume", 100) @@ -74,8 +83,10 @@ class MainWindow(QMainWindow): # Connect to server if one is configured if self.settings.hasServers(): + self.logger.info("Servers present, attempting default connection") self.connectToDefaultServer() else: + self.logger.info("No servers configured; showing setup dialog") self.showServerSetup() # ============ UI setup ============ @@ -171,8 +182,7 @@ class MainWindow(QMainWindow): # View menu viewMenu = menuBar.addMenu("&View") - self.searchAction = QAction("&Search...", self) - self.searchAction.setShortcut(QKeySequence("Ctrl+F")) + self.searchAction = QAction("&Search...\tCtrl+F", self) self.searchAction.setEnabled(False) viewMenu.addAction(self.searchAction) @@ -368,6 +378,11 @@ class MainWindow(QMainWindow): self.announceTrackLiveShortcut = QShortcut(QKeySequence("Alt+C"), self) self.announceTrackLiveShortcut.activated.connect(self.announceCurrentTrackLive) + # Explicit shortcut to open search dialog regardless of focus + self.searchShortcut = QShortcut(QKeySequence("Ctrl+F"), self) + self.searchShortcut.setContext(Qt.ApplicationShortcut) + self.searchShortcut.activated.connect(self.openSearchDialog) + self.previousShortcut = QShortcut(QKeySequence("Z"), self) self.previousShortcut.activated.connect(self.handlePreviousShortcut) @@ -433,17 +448,24 @@ class MainWindow(QMainWindow): dialog = ServerDialog(self.settings, parent=self) if dialog.exec(): serverName = dialog.getServerName() + self.logger.info("Server setup completed, connecting to '%s'", serverName) self.connectToServer(serverName) + else: + self.logger.info("Server setup dialog canceled") def connectToDefaultServer(self): """Connect to the default server""" defaultServer = self.settings.getDefaultServer() if defaultServer: + self.logger.info("Connecting to default server '%s'", defaultServer) self.connectToServer(defaultServer) else: servers = self.settings.getServers() if servers: + self.logger.info("No default server set; connecting to first configured") self.connectToServer(list(servers.keys())[0]) + else: + self.logger.warning("connectToDefaultServer called with no servers configured") def connectToServer(self, serverName: str): """Connect to a specific server""" @@ -457,32 +479,45 @@ class MainWindow(QMainWindow): return self.connectionStatus.setText(f"Connecting to {serverName}...") + self._run_background( + "connect", + lambda: self._ping_server(server), + on_success=lambda client: self._on_connection_success(serverName, client), + on_error=lambda err: self.handleConnectionFailure(f"Failed to connect: {err}") + ) - try: - self.client = SubsonicClient( - server['url'], - server['username'], - server['password'] - ) + def _ping_server(self, server: Dict[str, str]) -> SubsonicClient: + """Create client and ping server off the UI thread.""" + client = SubsonicClient( + server['url'], + server['username'], + server['password'] + ) + self.logger.info("Pinging %s", server.get("url")) + if not client.ping(): + self.logger.error("Ping failed for %s", server.get("url")) + raise SubsonicError(70, "Failed to connect to server. Please check your settings.") + self.logger.info("Ping succeeded for %s", server.get("url")) + return client - if self.client.ping(): - self.connectionStatus.setText(f"Connected to {serverName}") - self.playback.setStreamResolver(lambda song: self.client.getStreamUrl(song.id)) - self.onConnected() - else: - self.handleConnectionFailure("Failed to connect to server. Please check your settings.") - except SubsonicError as e: - self.handleConnectionFailure(f"Failed to connect: {e.message}") - except Exception as e: - self.handleConnectionFailure(f"Failed to connect: {str(e)}") + def _on_connection_success(self, serverName: str, client: SubsonicClient): + """Handle successful connection on the UI thread.""" + self.logger.info("Connected to %s, starting connected flow", serverName) + self.client = client + self.connectionStatus.setText(f"Connected to {serverName}") + self.playback.setStreamResolver(lambda song: self.client.getStreamUrl(song.id)) + self.onConnected() + self.logger.info("Connected to %s", serverName) def handleConnectionFailure(self, message: str): """Handle errors when connecting to the server.""" self.connectionStatus.setText("Connection failed") AccessibleTextDialog.showError("Connection Error", message, parent=self) + self.logger.error("Connection failed: %s", message) def onConnected(self): """Called when successfully connected to a server""" + self.logger.info("onConnected: enabling actions and refreshing library") self.refreshAction.setEnabled(True) self.searchAction.setEnabled(True) self.libraryTree.setEnabled(True) @@ -496,8 +531,49 @@ class MainWindow(QMainWindow): if not self.client: return + self.refreshAction.setEnabled(False) + self.searchAction.setEnabled(False) self.libraryTree.clear() + self.libraryTree.setEnabled(False) self.libraryStatus.setText("Loading artists, playlists, and genres...") + self.logger.info("Refreshing library...") + + self._run_background( + "load library", + self._fetchLibraryData, + on_success=self._applyLibraryData, + on_error=lambda err: self._handleLibraryLoadError(str(err)) + ) + + def _fetchLibraryData(self) -> Dict[str, Any]: + """Fetch core library sections in the background.""" + if not self.client: + raise SubsonicError(70, "Not connected") + artists = self.client.getArtists() + playlists = self.client.getPlaylists() + genres = self.client.getGenres() + favorites = self.client.getStarred() + self.logger.info( + "Fetched library data (artists=%d, playlists=%d, genres=%d, favorites songs=%d)", + len(artists), + len(playlists), + len(genres), + len(favorites.get("songs", [])) if isinstance(favorites, dict) else 0, + ) + return { + "artists": artists, + "playlists": playlists, + "genres": genres, + "favorites": favorites, + } + + def _applyLibraryData(self, data: Dict[str, Any]): + """Populate the library tree with fetched data on the UI thread.""" + if not self.client: + return + self.logger.info("Library data fetched; building tree") + + self.libraryTree.clear() # Root sections self.artistsRoot = QTreeWidgetItem(["Artists"]) @@ -509,7 +585,7 @@ class MainWindow(QMainWindow): self.favoritesRoot.setData(0, Qt.UserRole, {"type": "favorites_root", "loaded": True}) self.favoritesRoot.setExpanded(True) self.libraryTree.addTopLevelItem(self.favoritesRoot) - self.loadFavorites() + self._populateFavorites(data.get("favorites", {})) self.discoverRoot = QTreeWidgetItem(["Discover"]) self.discoverRoot.setData(0, Qt.UserRole, {"type": "discover_root", "loaded": True}) @@ -527,22 +603,36 @@ class MainWindow(QMainWindow): self.genresRoot.setExpanded(True) self.libraryTree.addTopLevelItem(self.genresRoot) - try: - self.loadArtists() - self.loadPlaylists() - self.loadGenres() - self.libraryStatus.setText("Library ready. Use the tree to browse and press Enter to play.") - except Exception as e: - AccessibleTextDialog.showError( - "Error", - f"Failed to load library: {str(e)}", - parent=self - ) - self.libraryStatus.setText("Library failed to load.") + self._populateArtists(data.get("artists", [])) + self._populatePlaylists(data.get("playlists", [])) + self._populateGenres(data.get("genres", [])) - def loadArtists(self): - """Populate top-level artists.""" - artists = self.client.getArtists() + self.libraryStatus.setText("Library ready. Use the tree to browse and press Enter to play.") + self.libraryTree.setEnabled(True) + if self.artistsRoot and self.artistsRoot.childCount() > 0: + self.libraryTree.setCurrentItem(self.artistsRoot) + self.libraryTree.setFocus(Qt.OtherFocusReason) + self.refreshAction.setEnabled(True) + self.searchAction.setEnabled(True) + self.logger.info("Library ready with sections: artists=%d playlists=%d genres=%d", + self.artistsRoot.childCount(), + self.playlistsRoot.childCount(), + self.genresRoot.childCount()) + + if all(root.childCount() == 0 for root in (self.artistsRoot, self.playlistsRoot, self.genresRoot)): + self.libraryStatus.setText("No library data returned from server.") + + def _handleLibraryLoadError(self, message: str): + """Show error when library fails to load.""" + AccessibleTextDialog.showError("Error", f"Failed to load library: {message}", parent=self) + self.libraryStatus.setText("Library failed to load.") + self.refreshAction.setEnabled(True) + self.searchAction.setEnabled(bool(self.client)) + self.libraryTree.setEnabled(True) + self.logger.error("Library load failed: %s", message) + + def _populateArtists(self, artists: List[Artist]): + """Populate top-level artists from fetched data.""" for artist in artists: item = QTreeWidgetItem([artist.name]) description = f"{artist.albumCount} albums" if artist.albumCount else "Artist" @@ -551,9 +641,8 @@ class MainWindow(QMainWindow): item.addChild(QTreeWidgetItem(["Loading albums..."])) self.artistsRoot.addChild(item) - def loadPlaylists(self): - """Populate top-level playlists.""" - playlists = self.client.getPlaylists() + def _populatePlaylists(self, playlists: List[Playlist]): + """Populate top-level playlists from fetched data.""" for playlist in playlists: label = f"{playlist.name} ({playlist.songCount} tracks)" item = QTreeWidgetItem([label]) @@ -562,9 +651,8 @@ class MainWindow(QMainWindow): item.addChild(QTreeWidgetItem(["Loading tracks..."])) self.playlistsRoot.addChild(item) - def loadGenres(self): - """Populate top-level genres.""" - genres = self.client.getGenres() + def _populateGenres(self, genres: List[Genre]): + """Populate top-level genres from fetched data.""" for genre in genres: label = f"{genre.name} ({genre.songCount} songs)" item = QTreeWidgetItem([label]) @@ -573,10 +661,9 @@ class MainWindow(QMainWindow): item.addChild(QTreeWidgetItem(["Loading songs..."])) self.genresRoot.addChild(item) - def loadFavorites(self): - """Populate the favorites tree with starred songs.""" - starred = self.client.getStarred() - songs: List[Song] = starred.get("songs", []) + def _populateFavorites(self, starred: Dict[str, List[Song]]): + """Populate favorites with pre-fetched starred songs.""" + songs: List[Song] = starred.get("songs", []) if starred else [] self.favoritesRoot.takeChildren() if not songs: self.favoritesRoot.addChild(QTreeWidgetItem(["No favorites yet."])) @@ -621,31 +708,57 @@ class MainWindow(QMainWindow): data: Dict = item.data(0, Qt.UserRole) or {} if data.get("loaded"): return + itemType = data.get("type") + self._setLoadingPlaceholder(item, "Loading...") - try: - item.takeChildren() - itemType = data.get("type") - if itemType == "artist": - self.populateArtistAlbums(item, data["id"]) - elif itemType == "album": - self.populateAlbumSongs(item, data["id"]) - elif itemType == "playlist": - self.populatePlaylistSongs(item, data["id"]) - elif itemType == "genre": - self.populateGenreSongs(item, data["name"]) - elif itemType == "album_list": - self.populateAlbumListSection(item, data.get("listType", "newest")) - elif itemType == "now_playing": - self.populateNowPlayingSection(item) - elif itemType == "similar_current": - self.populateSimilarToCurrent(item) - data["loaded"] = True - item.setData(0, Qt.UserRole, data) - except Exception as e: - AccessibleTextDialog.showError( - "Library Error", - f"Could not load content: {str(e)}", - parent=self + if itemType == "artist": + self._run_background( + "load artist albums", + lambda: self._fetchArtistAlbums(data.get("id")), + on_success=lambda albums: self._applyArtistAlbums(item, albums), + on_error=lambda err: self._handleItemLoadError(item, str(err)) + ) + elif itemType == "album": + self._run_background( + "load album songs", + lambda: self._fetchAlbumSongs(data.get("id")), + on_success=lambda songs: self._applyAlbumSongs(item, songs), + on_error=lambda err: self._handleItemLoadError(item, str(err)) + ) + elif itemType == "playlist": + self._run_background( + "load playlist songs", + lambda: self._fetchPlaylistSongs(data.get("id")), + on_success=lambda playlist: self._applyPlaylistSongs(item, playlist), + on_error=lambda err: self._handleItemLoadError(item, str(err)) + ) + elif itemType == "genre": + self._run_background( + "load genre songs", + lambda: self._fetchGenreSongs(data.get("name")), + on_success=lambda songs: self._applyGenreSongs(item, songs), + on_error=lambda err: self._handleItemLoadError(item, str(err)) + ) + elif itemType == "album_list": + self._run_background( + "load discover section", + lambda: self._fetchAlbumList(data.get("listType", "newest")), + on_success=lambda albums: self._applyAlbumListSection(item, albums), + on_error=lambda err: self._handleItemLoadError(item, str(err)) + ) + elif itemType == "now_playing": + self._run_background( + "load now playing", + self._fetchNowPlaying, + on_success=lambda songs: self._applyNowPlaying(item, songs), + on_error=lambda err: self._handleItemLoadError(item, str(err)) + ) + elif itemType == "similar_current": + self._run_background( + "load similar songs", + self._fetchSimilarToCurrent, + on_success=lambda songs: self._applySimilarToCurrent(item, songs), + on_error=lambda err: self._handleItemLoadError(item, str(err)) ) def onLibraryActivated(self, item: QTreeWidgetItem): @@ -736,10 +849,14 @@ class MainWindow(QMainWindow): globalPos = self.libraryTree.viewport().mapToGlobal(position) menu.exec(globalPos) - def populateArtistAlbums(self, parentItem: QTreeWidgetItem, artistId: str): - """Add albums for a given artist.""" + def _fetchArtistAlbums(self, artistId: str) -> List[Album]: + """Fetch albums for a given artist off the UI thread.""" result = self.client.getArtist(artistId) - albums: List[Album] = result.get("albums", []) + return result.get("albums", []) + + def _applyArtistAlbums(self, parentItem: QTreeWidgetItem, albums: List[Album]): + """Add albums for a given artist on the UI thread.""" + parentItem.takeChildren() for album in albums: label = f"{album.name} ({album.songCount} tracks)" albumItem = QTreeWidgetItem([label]) @@ -747,44 +864,61 @@ class MainWindow(QMainWindow): albumItem.setData(0, Qt.AccessibleTextRole, label) albumItem.addChild(QTreeWidgetItem(["Loading tracks..."])) parentItem.addChild(albumItem) + self._markItemLoaded(parentItem) - def populateAlbumSongs(self, parentItem: QTreeWidgetItem, albumId: str): - """Add songs for a given album.""" + def _fetchAlbumSongs(self, albumId: str) -> List[Song]: + """Fetch songs for a given album off the UI thread.""" result = self.client.getAlbum(albumId) - songs: List[Song] = result.get("songs", []) + return result.get("songs", []) + + def _applyAlbumSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]): + """Add songs for a given album on the UI thread.""" + parentItem.takeChildren() for song in songs: label = self.formatSongLabel(song) songItem = QTreeWidgetItem([label]) songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) songItem.setData(0, Qt.AccessibleTextRole, label) parentItem.addChild(songItem) + self._markItemLoaded(parentItem) - def populatePlaylistSongs(self, parentItem: QTreeWidgetItem, playlistId: str): - """Add songs for a playlist.""" - playlist: Playlist = self.client.getPlaylist(playlistId) + def _fetchPlaylistSongs(self, playlistId: str) -> Playlist: + """Fetch playlist with songs off the UI thread.""" + return self.client.getPlaylist(playlistId) + + def _applyPlaylistSongs(self, parentItem: QTreeWidgetItem, playlist: Playlist): + """Add songs for a playlist on the UI thread.""" + parentItem.takeChildren() for song in playlist.songs: label = self.formatSongLabel(song, includeAlbum=True) songItem = QTreeWidgetItem([label]) songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) songItem.setData(0, Qt.AccessibleTextRole, label) parentItem.addChild(songItem) - parentItem.setData(0, Qt.UserRole, {"type": "playlist", "id": playlistId, "playlist": playlist, "loaded": True}) + parentItem.setData(0, Qt.UserRole, {"type": "playlist", "id": playlist.id, "playlist": playlist, "loaded": True}) - def populateGenreSongs(self, parentItem: QTreeWidgetItem, genreName: str): - """Add songs for a genre (limited batch).""" - songs = self.client.getSongsByGenre(genreName, count=200) + def _fetchGenreSongs(self, genreName: str) -> List[Song]: + """Fetch songs for a genre off the UI thread.""" + return self.client.getSongsByGenre(genreName, count=200) + + def _applyGenreSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]): + """Add songs for a genre on the UI thread.""" + parentItem.takeChildren() for song in songs: label = self.formatSongLabel(song, includeAlbum=True) songItem = QTreeWidgetItem([label]) songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) songItem.setData(0, Qt.AccessibleTextRole, label) parentItem.addChild(songItem) - parentItem.setData(0, Qt.UserRole, {"type": "genre", "name": genreName, "loaded": True}) + self._markItemLoaded(parentItem) - def populateAlbumListSection(self, parentItem: QTreeWidgetItem, listType: str): - """Populate a discover album list section.""" + def _fetchAlbumList(self, listType: str) -> List[Album]: + """Fetch album list for discover section off the UI thread.""" + return self.client.getAlbumList(listType, size=40) + + def _applyAlbumListSection(self, parentItem: QTreeWidgetItem, albums: List[Album]): + """Populate a discover album list section on the UI thread.""" parentItem.takeChildren() - albums = self.client.getAlbumList(listType, size=40) for album in albums: label = f"{album.name} — {album.artist} ({album.songCount} tracks)" albumItem = QTreeWidgetItem([label]) @@ -792,11 +926,15 @@ class MainWindow(QMainWindow): albumItem.setData(0, Qt.AccessibleTextRole, label) albumItem.addChild(QTreeWidgetItem(["Loading tracks..."])) parentItem.addChild(albumItem) + self._markItemLoaded(parentItem) - def populateNowPlayingSection(self, parentItem: QTreeWidgetItem): - """Populate songs currently playing across users.""" + def _fetchNowPlaying(self) -> List[Song]: + """Fetch songs currently playing across users off the UI thread.""" + return self.client.getNowPlaying() + + def _applyNowPlaying(self, parentItem: QTreeWidgetItem, songs: List[Song]): + """Populate songs currently playing across users on the UI thread.""" parentItem.takeChildren() - songs = self.client.getNowPlaying() if not songs: parentItem.addChild(QTreeWidgetItem(["Nothing is currently playing."])) return @@ -806,15 +944,22 @@ class MainWindow(QMainWindow): songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) songItem.setData(0, Qt.AccessibleTextRole, label) parentItem.addChild(songItem) + self._markItemLoaded(parentItem) - def populateSimilarToCurrent(self, parentItem: QTreeWidgetItem): - """Populate songs similar to the current track.""" + def _fetchSimilarToCurrent(self) -> List[Song]: + """Fetch songs similar to the current track off the UI thread.""" + current = self.playback.currentSong() + if not current: + return [] + return self.client.getSimilarSongs(current.id, count=40) + + def _applySimilarToCurrent(self, parentItem: QTreeWidgetItem, songs: List[Song]): + """Populate songs similar to the current track on the UI thread.""" parentItem.takeChildren() current = self.playback.currentSong() if not current: parentItem.addChild(QTreeWidgetItem(["Play a track to see similar songs."])) return - songs = self.client.getSimilarSongs(current.id, count=40) if not songs: parentItem.addChild(QTreeWidgetItem([f"No similar tracks found for {current.title}."])) return @@ -824,6 +969,7 @@ class MainWindow(QMainWindow): songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) songItem.setData(0, Qt.AccessibleTextRole, label) parentItem.addChild(songItem) + self._markItemLoaded(parentItem) @staticmethod def formatSongLabel(song: Song, includeAlbum: bool = False) -> str: @@ -880,67 +1026,56 @@ class MainWindow(QMainWindow): self.enablePlaybackActions(True) return - songs, label = self.collectSongsForItem(item) - if not songs: - self.announceLive(f"No songs found for {label}") - return - - self.playback.enqueueMany(songs, playFirst=False) - self.statusBar.showMessage(f"Added {len(songs)} tracks from {label} to the queue", 4000) - self.enablePlaybackActions(True) + self.statusBar.showMessage("Collecting songs...", 2000) + self._run_background( + "collect songs for queue", + lambda: self.collectSongsForItem(item), + on_success=self._onSongsCollectedForQueue, + on_error=lambda err: self._handleCollectionError(str(err)) + ) def addItemToPlaylist(self, item: QTreeWidgetItem): """Append the item's songs to an existing or new playlist.""" if not self.client: self.announceLive("Not connected to a server.") return - - songs, label = self.collectSongsForItem(item) - if not songs: - self.announceLive(f"No songs found for {label}") + data: Dict = item.data(0, Qt.UserRole) or {} + itemType = data.get("type") + if itemType == "song": + song: Optional[Song] = data.get("song") + songs = [song] if song else [] + label = self.formatSongLabel(song, includeAlbum=True) if song else item.text(0) + self._collectPlaylistsAndShowDialog(songs, label, truncated=False) return - playlists = self.client.getPlaylists() - dialog = AddToPlaylistDialog(playlists, parent=self) - if not dialog.exec(): - return + self.statusBar.showMessage("Collecting songs...", 2000) + self._run_background( + "collect songs for playlist", + lambda: self.collectSongsForItem(item), + on_success=self._onSongsCollectedForPlaylist, + on_error=lambda err: self._handleCollectionError(str(err)) + ) - mode, target = dialog.getResult() - songIds = [song.id for song in songs] - - try: - if mode == "existing": - self.client.updatePlaylist(target, songIdsToAdd=songIds) - targetName = next((p.name for p in playlists if p.id == target), "playlist") - message = f"Added {len(songs)} tracks to {targetName}" - self.statusBar.showMessage(message, 3000) - self.announceLive(message) - else: - playlist = self.client.createPlaylist(target, songIds=songIds) - message = f"Created playlist {playlist.name} with {len(songs)} tracks" - self.statusBar.showMessage(message, 4000) - self.announceLive(message) - except Exception as e: - AccessibleTextDialog.showError("Playlist Error", str(e), parent=self) - - def collectSongsForItem(self, item: QTreeWidgetItem) -> Tuple[List[Song], str]: - """Resolve a tree item into a list of songs for queueing.""" + def collectSongsForItem(self, item: QTreeWidgetItem) -> Tuple[List[Song], str, bool]: + """Resolve a tree item into a list of songs for queueing or playlist actions.""" data: Dict = item.data(0, Qt.UserRole) or {} itemType = data.get("type") label = item.text(0) + truncated = False + request_count = 0 if not self.client: - return [], label + return [], label, truncated try: if itemType == "song": song: Optional[Song] = data.get("song") - return [song] if song else [], self.formatSongLabel(song, includeAlbum=True) if song else label + return ([song] if song else []), (self.formatSongLabel(song, includeAlbum=True) if song else label), truncated if itemType == "album": albumData = self.client.getAlbum(data.get("id")) album: Optional[Album] = albumData.get("album") songs: List[Song] = albumData.get("songs", []) - return songs, album.name if album else label + return songs, (album.name if album else label), truncated if itemType == "artist": artistData = self.client.getArtist(data.get("id")) artist: Optional[Artist] = artistData.get("artist") @@ -949,14 +1084,16 @@ class MainWindow(QMainWindow): for album in albums: albumData = self.client.getAlbum(album.id) songs.extend(albumData.get("songs", [])) - return songs, artist.name if artist else label + request_count += 1 + self._throttle_requests(request_count) + return songs, (artist.name if artist else label), truncated if itemType == "playlist": playlist: Playlist = self.client.getPlaylist(data.get("id")) - return playlist.songs, playlist.name + return playlist.songs, playlist.name, truncated if itemType == "genre": genreName = data.get("name") - songs = self.client.getSongsByGenre(genreName, count=5000) - return songs, genreName or label + songs = self.client.getSongsByGenre(genreName) + return songs, (genreName or label), truncated if itemType == "artists_root": artists: List[Artist] = self.client.getArtists() songs: List[Song] = [] @@ -965,37 +1102,107 @@ class MainWindow(QMainWindow): for album in artistData.get("albums", []): albumData = self.client.getAlbum(album.id) songs.extend(albumData.get("songs", [])) - return songs, "all artists" + request_count += 1 + self._throttle_requests(request_count) + return songs, "all artists", truncated if itemType == "favorites_root": starred = self.client.getStarred() songs = starred.get("songs", []) - return songs, "favorites" - if itemType == "playlist": - playlist: Playlist = self.client.getPlaylist(data.get("id")) - return playlist.songs, playlist.name + return songs, "favorites", truncated if itemType == "album_list": # These nodes contain album children; we don't aggregate here - return [], label + return [], label, truncated if itemType == "now_playing": songs = self.client.getNowPlaying() - return songs, "now playing" + return songs, "now playing", truncated if itemType == "similar_current": current = self.playback.currentSong() songs = self.client.getSimilarSongs(current.id, count=40) if current else [] - return songs, "similar tracks" + return songs, "similar tracks", truncated except Exception as e: AccessibleTextDialog.showError("Queue Error", str(e), parent=self) - return [], label + return [], label, truncated + + def _onSongsCollectedForQueue(self, result: Tuple[List[Song], str, bool]): + """Handle async collection results for queueing.""" + songs, label, truncated = result + if not songs: + self.announceLive(f"No songs found for {label}") + return + self.playback.enqueueMany(songs, playFirst=False) + message = f"Added {len(songs)} tracks from {label} to the queue" + self.statusBar.showMessage(message, 4000) + self.enablePlaybackActions(True) + self.announceLive(message) + + def _handleCollectionError(self, message: str): + """Surface collection errors consistently.""" + AccessibleTextDialog.showError("Queue Error", message, parent=self) + + def _onSongsCollectedForPlaylist(self, result: Tuple[List[Song], str, bool]): + """Handle async collection results before showing playlist dialog.""" + songs, label, truncated = result + self._collectPlaylistsAndShowDialog(songs, label, truncated) + + def _collectPlaylistsAndShowDialog(self, songs: List[Song], label: str, truncated: bool): + """Fetch playlists in the background, then show the dialog on the UI thread.""" + if not songs: + self.announceLive(f"No songs found for {label}") + return + self._run_background( + "load playlists", + self.client.getPlaylists, + on_success=lambda playlists: self._showAddToPlaylistDialog(playlists, songs, label, truncated), + on_error=lambda err: AccessibleTextDialog.showError("Playlist Error", str(err), parent=self) + ) + + def _showAddToPlaylistDialog(self, playlists: List[Playlist], songs: List[Song], label: str, truncated: bool): + """Open playlist dialog and dispatch playlist updates in the background.""" + dialog = AddToPlaylistDialog(playlists, parent=self) + if not dialog.exec(): + return + + mode, target = dialog.getResult() + songIds = [song.id for song in songs] + self._run_background( + "update playlist", + lambda: self._updateOrCreatePlaylist(mode, target, playlists, songIds), + on_success=lambda message: self._afterPlaylistUpdated(message, len(songs), truncated), + on_error=lambda err: AccessibleTextDialog.showError("Playlist Error", str(err), parent=self) + ) + + def _updateOrCreatePlaylist(self, mode: str, target: str, playlists: List[Playlist], songIds: List[str]) -> str: + """Perform the playlist mutation off the UI thread and return a status message.""" + if mode == "existing": + self.client.updatePlaylist(target, songIdsToAdd=songIds) + targetName = next((p.name for p in playlists if p.id == target), "playlist") + return f"Added tracks to {targetName}" + playlist = self.client.createPlaylist(target, songIds=songIds) + return f"Created playlist {playlist.name}" + + def _afterPlaylistUpdated(self, baseMessage: str, count: int, truncated: bool): + """Announce playlist mutation results on the UI thread.""" + suffix = f" ({count} tracks)" + message = f"{baseMessage} {suffix}" + self.statusBar.showMessage(message, 4000) + self.announceLive(message) # ============ Search ============ def openSearchDialog(self): """Open the search dialog.""" if not self.client: + self.logger.warning("Search requested but no client is connected") + self.announceLive("Connect to a server first to search.") return - dialog = SearchDialog(self.client, parent=self) - dialog.songActivated.connect(self.onSearchSongChosen) - dialog.exec() + self.logger.info("Opening search dialog") + try: + dialog = SearchDialog(self.client, parent=self) + dialog.songActivated.connect(self.onSearchSongChosen) + dialog.exec() + self.logger.info("Search dialog closed") + except Exception as exc: # noqa: BLE001 + self.logger.exception("Failed to open search dialog: %s", exc) def onSearchSongChosen(self, song: Song): """Handle song selection from search.""" @@ -1065,104 +1272,190 @@ class MainWindow(QMainWindow): if not self.client or not albumId: self.announceLive("No album selected.") return - try: - albumData = self.client.getAlbum(albumId) - album: Optional[Album] = albumData.get("album") - songs: List[Song] = albumData.get("songs", []) - self.startPlaybackWithSongs(songs, f"album {album.name if album else albumId}") - if item and songs: - self._populateAlbumItemWithSongs(item, songs) - item.setExpanded(True) - except Exception as e: - AccessibleTextDialog.showError("Album Error", str(e), parent=self) + self.statusBar.showMessage("Loading album...", 2000) + self._run_background( + "play album", + lambda: self._fetchAlbumWithSongs(albumId), + on_success=lambda data: self._onAlbumLoadedForPlay(albumId, item, data), + on_error=lambda err: AccessibleTextDialog.showError("Album Error", str(err), parent=self) + ) def playArtist(self, artistId: str, item: Optional[QTreeWidgetItem] = None): """Play all songs by an artist across their albums.""" if not self.client or not artistId: self.announceLive("No artist selected.") return - try: - artistData = self.client.getArtist(artistId) - artist: Optional[Artist] = artistData.get("artist") - albums: List[Album] = artistData.get("albums", []) - songs: List[Song] = [] - for album in albums: - albumData = self.client.getAlbum(album.id) - songs.extend(albumData.get("songs", [])) - self.startPlaybackWithSongs(songs, artist.name if artist else f"artist {artistId}") - if item: - item.setExpanded(True) - except Exception as e: - AccessibleTextDialog.showError("Artist Error", str(e), parent=self) + self.statusBar.showMessage("Loading artist...", 2000) + self._run_background( + "play artist", + lambda: self._fetchArtistSongs(artistId), + on_success=lambda payload: self._onArtistLoadedForPlay(item, payload), + on_error=lambda err: AccessibleTextDialog.showError("Artist Error", str(err), parent=self) + ) def playAllArtists(self): """Play all songs across all artists.""" if not self.client: self.announceLive("Not connected to a server.") return - try: - self.announceLive("Loading all artists...") - artists: List[Artist] = self.client.getArtists() - songs: List[Song] = [] - for artist in artists: - artistData = self.client.getArtist(artist.id) - for album in artistData.get("albums", []): - albumData = self.client.getAlbum(album.id) - songs.extend(albumData.get("songs", [])) - self.startPlaybackWithSongs(songs, "all artists") - except Exception as e: - AccessibleTextDialog.showError("Library Error", str(e), parent=self) + self.announceLive("Loading all artists...") + self._run_background( + "play all artists", + self._progressiveLoadAllArtists, + on_success=lambda count: self._announceLiveIfAny(count, "all artists"), + on_error=lambda err: AccessibleTextDialog.showError("Library Error", str(err), parent=self) + ) def playGenre(self, genreName: str, item: Optional[QTreeWidgetItem] = None): """Play all songs within a genre.""" if not self.client or not genreName: self.announceLive("No genre selected.") return - try: - songs = self.client.getSongsByGenre(genreName, count=5000) - self.startPlaybackWithSongs(songs, f"genre {genreName}") - if item and songs: - self._populateGenreItemWithSongs(item, songs) - item.setExpanded(True) - except Exception as e: - AccessibleTextDialog.showError("Genre Error", str(e), parent=self) + self.statusBar.showMessage("Loading genre...", 2000) + self._run_background( + "play genre", + lambda: self._fetchGenreSongsForPlay(genreName), + on_success=lambda result: self._onGenreLoadedForPlay(genreName, item, result), + on_error=lambda err: AccessibleTextDialog.showError("Genre Error", str(err), parent=self) + ) def playPlaylist(self, playlistId: str, item: Optional[QTreeWidgetItem] = None): """Load a playlist and start playback.""" - try: - playlist: Playlist = self.client.getPlaylist(playlistId) - songs = playlist.songs - if not songs: - AccessibleTextDialog.showWarning("Playlist Empty", "This playlist has no tracks.", parent=self) - return - self.playback.clearQueue() - self.playback.enqueueMany(songs, playFirst=True) - self.statusBar.showMessage(f"Playing playlist {playlist.name}", 4000) - if item: - # Mark as loaded and populate the tree view if needed - item.takeChildren() - for song in songs: - label = self.formatSongLabel(song, includeAlbum=True) - child = QTreeWidgetItem([label]) - child.setData(0, Qt.UserRole, {"type": "song", "song": song}) - child.setData(0, Qt.AccessibleTextRole, label) - item.addChild(child) - item.setData(0, Qt.UserRole, {"type": "playlist", "id": playlistId, "playlist": playlist, "loaded": True}) - self.enablePlaybackActions(True) - except Exception as e: - AccessibleTextDialog.showError("Playlist Error", str(e), parent=self) + self.statusBar.showMessage("Loading playlist...", 2000) + self._run_background( + "play playlist", + lambda: self.client.getPlaylist(playlistId), + on_success=lambda playlist: self._onPlaylistLoadedForPlay(item, playlist), + on_error=lambda err: AccessibleTextDialog.showError("Playlist Error", str(err), parent=self) + ) def playFavorites(self): """Play all starred songs.""" if not self.client: self.announceLive("Not connected to a server.") return - try: - starred = self.client.getStarred() - songs: List[Song] = starred.get("songs", []) - self.startPlaybackWithSongs(songs, "favorites") - except Exception as e: - AccessibleTextDialog.showError("Favorites Error", str(e), parent=self) + self.statusBar.showMessage("Loading favorites...", 2000) + self._run_background( + "play favorites", + lambda: self.client.getStarred(), + on_success=lambda starred: self.startPlaybackWithSongs(starred.get("songs", []), "favorites"), + on_error=lambda err: AccessibleTextDialog.showError("Favorites Error", str(err), parent=self) + ) + + # ============ Background fetch helpers for playback ============ + + def _fetchAlbumWithSongs(self, albumId: str) -> Tuple[Optional[Album], List[Song]]: + """Fetch album metadata and songs.""" + albumData = self.client.getAlbum(albumId) + return albumData.get("album"), albumData.get("songs", []) + + def _onAlbumLoadedForPlay(self, albumId: str, item: Optional[QTreeWidgetItem], data: Tuple[Optional[Album], List[Song]]): + """Start playback for a loaded album and populate UI if needed.""" + album, songs = data + self.startPlaybackWithSongs(songs, f"album {album.name if album else albumId}") + if item and songs: + self._populateAlbumItemWithSongs(item, songs) + item.setExpanded(True) + + def _fetchArtistSongs(self, artistId: str) -> Tuple[Optional[Artist], List[Song]]: + """Fetch all songs for an artist across albums with throttling.""" + artistData = self.client.getArtist(artistId) + artist: Optional[Artist] = artistData.get("artist") + albums: List[Album] = artistData.get("albums", []) + songs: List[Song] = [] + request_count = 0 + for album in albums: + albumData = self.client.getAlbum(album.id) + songs.extend(albumData.get("songs", [])) + request_count += 1 + self._throttle_requests(request_count) + return artist, songs + + def _onArtistLoadedForPlay(self, item: Optional[QTreeWidgetItem], payload: Tuple[Optional[Artist], List[Song]]): + """Start playback for a loaded artist and expand UI if present.""" + artist, songs = payload + label = artist.name if artist else "artist" + self.startPlaybackWithSongs(songs, label) + if item: + item.setExpanded(True) + + def _progressiveLoadAllArtists(self) -> int: + """Fetch songs across all artists in chunks and enqueue progressively.""" + artists: List[Artist] = self.client.getArtists() + batch: List[Song] = [] + request_count = 0 + total = 0 + first_batch = True + + for artist in artists: + artistData = self.client.getArtist(artist.id) + for album in artistData.get("albums", []): + albumData = self.client.getAlbum(album.id) + songs = albumData.get("songs", []) + batch.extend(songs) + total += len(songs) + request_count += 1 + if len(batch) >= self.bulkChunkSize: + self._on_ui(lambda b=batch.copy(), first=first_batch: self._enqueueBatchForPlayback(b, "all artists", first)) + batch.clear() + first_batch = False + self._throttle_requests(request_count) + + if batch: + self._on_ui(lambda b=batch.copy(), first=first_batch: self._enqueueBatchForPlayback(b, "all artists", first)) + + return total + + def _enqueueBatchForPlayback(self, batch: List[Song], label: str, first: bool): + """Enqueue a batch of songs, starting playback on the first batch.""" + if not batch: + return + if first: + self.playback.clearQueue() + self.playback.enqueueMany(batch, playFirst=True) + self.statusBar.showMessage(f"Playing {label}", 4000) + else: + self.playback.enqueueMany(batch, playFirst=False) + self.statusBar.showMessage(f"Queued {len(batch)} more tracks from {label}", 2000) + self.enablePlaybackActions(True) + + def _announceLiveIfAny(self, count: int, label: str): + """Announce completion if any tracks were queued.""" + if count > 0: + self.announceLive(f"Queued {count} tracks from {label}") + + def _fetchGenreSongsForPlay(self, genreName: str) -> Tuple[List[Song], bool]: + """Fetch songs for a genre with throttling for playback.""" + songs = self.client.getSongsByGenre(genreName) + return songs, False + + def _onGenreLoadedForPlay(self, genreName: str, item: Optional[QTreeWidgetItem], result: Tuple[List[Song], bool]): + """Start playback for a genre and populate UI if present.""" + songs, truncated = result + self.startPlaybackWithSongs(songs, f"genre {genreName}") + if item and songs: + self._populateGenreItemWithSongs(item, songs) + item.setExpanded(True) + + def _onPlaylistLoadedForPlay(self, item: Optional[QTreeWidgetItem], playlist: Playlist): + """Start playback for a playlist and populate UI if present.""" + songs = playlist.songs + if not songs: + AccessibleTextDialog.showWarning("Playlist Empty", "This playlist has no tracks.", parent=self) + return + self.playback.clearQueue() + self.playback.enqueueMany(songs, playFirst=True) + self.statusBar.showMessage(f"Playing playlist {playlist.name}", 4000) + if item: + item.takeChildren() + for song in songs: + label = self.formatSongLabel(song, includeAlbum=True) + child = QTreeWidgetItem([label]) + child.setData(0, Qt.UserRole, {"type": "song", "song": song}) + child.setData(0, Qt.AccessibleTextRole, label) + item.addChild(child) + item.setData(0, Qt.UserRole, {"type": "playlist", "id": playlist.id, "playlist": playlist, "loaded": True}) + self.enablePlaybackActions(True) def onTrackChanged(self, song: Song): """Update UI when track changes.""" @@ -1335,6 +1628,72 @@ class MainWindow(QMainWindow): # ============ Helpers ============ + def _setLoadingPlaceholder(self, item: QTreeWidgetItem, text: str): + """Replace children with a single loading placeholder.""" + item.takeChildren() + item.addChild(QTreeWidgetItem([text])) + + def _markItemLoaded(self, item: QTreeWidgetItem): + """Mark a tree item as loaded in its user data.""" + data: Dict = item.data(0, Qt.UserRole) or {} + data["loaded"] = True + item.setData(0, Qt.UserRole, data) + + def _handleItemLoadError(self, item: QTreeWidgetItem, message: str): + """Show a load error and restore a placeholder child.""" + AccessibleTextDialog.showError("Library Error", f"Could not load content: {message}", parent=self) + self._setLoadingPlaceholder(item, "Failed to load.") + + def _run_background( + self, + label: str, + func: Callable[[], Any], + on_success: Optional[Callable[[Any], None]] = None, + on_error: Optional[Callable[[Exception], None]] = None + ): + """Execute blocking work off the UI thread and marshal results back.""" + self.logger.info("Starting background task: %s", label) + future = self._executor.submit(func) + + def _done(fut): + try: + result = fut.result() + except Exception as exc: # noqa: BLE001 + self.logger.error("Background task '%s' failed: %s", label, exc) + if on_error: + self._on_ui(lambda: on_error(exc)) + return + if on_success: + self._on_ui(lambda: on_success(result)) + self.logger.info("Background task '%s' completed", label) + + future.add_done_callback(_done) + + def _on_ui(self, callback: Callable[[], None]): + """Post a callable to the Qt event loop with error logging.""" + def wrapped(): + try: + callback() + except Exception as exc: # noqa: BLE001 + self.logger.exception("UI callback failed: %s", exc) + # Ensure it runs on the main thread even when scheduled from workers + QTimer.singleShot(0, self, wrapped) + + def _throttle_requests(self, count: int): + """Sleep briefly every N requests to be gentle on the server.""" + if self.bulkRequestBatch <= 0 or self.bulkThrottleSeconds <= 0: + return + if count % self.bulkRequestBatch == 0: + time.sleep(self.bulkThrottleSeconds) + + def keyPressEvent(self, event): + """Handle high-priority shortcuts that might be consumed by child widgets.""" + if event.matches(QKeySequence.Find): + self.openSearchDialog() + event.accept() + return + super().keyPressEvent(event) + @staticmethod def formatTime(ms: int) -> str: """Convert milliseconds to mm:ss or hh:mm:ss.""" @@ -1557,4 +1916,7 @@ class MainWindow(QMainWindow): self.settings.save() if self.mpris: self.mpris.shutdown() + if self._executor: + self._executor.shutdown(wait=False) + self.logger.info("Main window closed") event.accept() diff --git a/src/widgets/search_dialog.py b/src/widgets/search_dialog.py index e446f53..9f8f632 100644 --- a/src/widgets/search_dialog.py +++ b/src/widgets/search_dialog.py @@ -18,6 +18,7 @@ from PySide6.QtWidgets import ( QPushButton, QTreeWidgetItem, QVBoxLayout, + QAbstractItemView, ) from src.api.client import SubsonicClient @@ -75,6 +76,12 @@ class SearchDialog(QDialog): self.resultsTree.setHeaderHidden(True) self.resultsTree.setRootIsDecorated(True) self.resultsTree.setContextMenuPolicy(Qt.CustomContextMenu) + self.resultsTree.setUniformRowHeights(True) + self.resultsTree.setItemsExpandable(True) + self.resultsTree.setExpandsOnDoubleClick(True) + self.resultsTree.setSelectionMode(QAbstractItemView.SingleSelection) + self.resultsTree.setAllColumnsShowFocus(True) + self.resultsTree.setFocusPolicy(Qt.StrongFocus) self.resultsTree.itemActivated.connect(self.handleActivate) self.resultsTree.itemExpanded.connect(self.onItemExpanded) self.resultsTree.customContextMenuRequested.connect(self.showContextMenu) @@ -112,10 +119,7 @@ class SearchDialog(QDialog): self.populateResults(results) if self._resultsLoaded: - self.resultsTree.setFocus() - first = self.resultsTree.topLevelItem(0) - if first: - self.resultsTree.setCurrentItem(first) + self._focusFirstResult() self.statusLabel.setText("Search complete. Use the tree to play or queue items.") else: self.statusLabel.setText("No results found.") @@ -123,6 +127,15 @@ class SearchDialog(QDialog): AccessibleTextDialog.showError("Search Error", str(e), parent=self) self.statusLabel.setText("Search failed.") + def _focusFirstResult(self): + """Move focus to the first result item for immediate keyboard navigation.""" + self.resultsTree.setFocus(Qt.OtherFocusReason) + first = self.resultsTree.topLevelItem(0) + if not first: + return + target = first.child(0) if first.childCount() else first + self.resultsTree.setCurrentItem(target) + def populateResults(self, results: Dict[str, List]): """Populate the results tree from search3 data.""" artists: List[Artist] = results.get("artists", []) @@ -131,8 +144,7 @@ class SearchDialog(QDialog): if artists: root = QTreeWidgetItem([f"Artists ({len(artists)})"]) - root.setData(0, Qt.UserRole, {"type": "artists_root"}) - root.setExpanded(True) + root.setData(0, Qt.UserRole, {"type": "artists_root", "loaded": True}) for artist in artists: item = QTreeWidgetItem([artist.name]) item.setData(0, Qt.UserRole, {"type": "artist", "id": artist.id, "artist": artist, "loaded": False}) @@ -140,12 +152,12 @@ class SearchDialog(QDialog): item.addChild(QTreeWidgetItem(["Loading..."])) root.addChild(item) self.resultsTree.addTopLevelItem(root) + self.resultsTree.expandItem(root) self._resultsLoaded = True if albums: root = QTreeWidgetItem([f"Albums ({len(albums)})"]) - root.setData(0, Qt.UserRole, {"type": "albums_root"}) - root.setExpanded(True) + root.setData(0, Qt.UserRole, {"type": "albums_root", "loaded": True}) for album in albums: label = f"{album.name} — {album.artist} ({album.songCount} tracks)" item = QTreeWidgetItem([label]) @@ -154,6 +166,7 @@ class SearchDialog(QDialog): item.addChild(QTreeWidgetItem(["Loading..."])) root.addChild(item) self.resultsTree.addTopLevelItem(root) + self.resultsTree.expandItem(root) self._resultsLoaded = True if songs: @@ -164,8 +177,7 @@ class SearchDialog(QDialog): if not songs: return root = QTreeWidgetItem([rootLabel]) - root.setData(0, Qt.UserRole, {"type": "songs_root"}) - root.setExpanded(True) + root.setData(0, Qt.UserRole, {"type": "songs_root", "loaded": True}) for song in songs: text = f"{song.title} — {song.artist} ({song.album}, {song.durationFormatted})" item = QTreeWidgetItem([text]) @@ -174,6 +186,7 @@ class SearchDialog(QDialog): root.addChild(item) self.resultsTree.addTopLevelItem(root) self._resultsLoaded = True + self.resultsTree.expandItem(root) def handleActivate(self, item: QTreeWidgetItem): """Activate the selected item similar to the main tree.""" @@ -230,6 +243,8 @@ class SearchDialog(QDialog): item.addChild(child) data["loaded"] = True item.setData(0, Qt.UserRole, data) + # Update accessibility for newly added children + self.resultsTree.updateChildAccessibility(item, True) except Exception as e: AccessibleTextDialog.showError("Search Error", str(e), parent=self)