diff --git a/README.md b/README.md index 15db5aa..81ff33b 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,18 @@ Accessible Navidrome desktop client built with PySide6. Keyboard-first controls, live region announcements, and XDG-compliant configuration keep the app friendly for screen readers and shared systems. +This project is vibe coded. If you have problems with fully AI generated code, move along, nothing to see here. For people with a brain, keep reading, you will be impressed. + ## Features - Browse artists, albums, playlists, and genres from your Navidrome server - Keyboard-driven playback controls with live announcements - Queue management (play now, next/previous, clear/remove) - Track-change announcements (toggle in View → Announce Track Changes) +- Context menus on library/search trees (right click or Shift+F10/Menu) for play, queue, favorite +- Search dialog mirrors the tree (artists/albums/songs/genres) with expand/collapse, Enter to play, and context menus +- Discover section with recently added/played, frequently played, random albums, server “Now Playing”, and “Similar to current track” +- Favorites section listing all starred songs; press Enter to play all or open individual tracks +- Add-to-playlist from songs/albums/artists/genres/favorites via context menu (choose existing or create new) - Desktop integrations via MPRIS (playback control, metadata, notifications) - Config, data, and cache saved under XDG paths (`~/.config/stormux/navipy`, etc.) - Does not log credentials; server secrets live in `servers.json` @@ -31,7 +38,7 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`. ## Configuration and Data Paths - Config: `~/.config/stormux/navipy/settings.json` and `servers.json` - Honors `$XDG_CONFIG_HOME`. Reads legacy `~/.config/navipy` if present. + Honors `$XDG_CONFIG_HOME`. Default path is `~/.config/navipy`. - Data (logs, runtime files): `~/.local/share/stormux/navipy/` (`$XDG_DATA_HOME` respected) - Cache: `~/.cache/stormux/navipy/` (`$XDG_CACHE_HOME` respected) @@ -40,7 +47,7 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`. - Navigation: `Z` previous, `B` next, `Ctrl+Left/Right` previous/next, `Alt+S` shuffle, `Alt+R` repeat - Announcements: `Alt+C` announce current track (live region), `Ctrl+T` announce track (dialog), `Ctrl+P` announce position - Volume: `Ctrl+Up/Down` or `0/9` to adjust -- Library/Search: `Ctrl+O` connect, `F5` refresh, `Ctrl+F` search +- Library/Search: `Ctrl+O` connect, `F5` refresh, `Ctrl+F` search, `Shift+F10` or Menu key for item context menu ## Playback Persistence - Volume, shuffle, and repeat preferences persist in `settings.json` diff --git a/src/api/client.py b/src/api/client.py index b45e6e5..2f767db 100644 --- a/src/api/client.py +++ b/src/api/client.py @@ -257,6 +257,42 @@ class SubsonicClient: genresData = response.get('genres', {}) return [Genre.fromDict(g) for g in genresData.get('genre', [])] + def getAlbumList(self, listType: str, size: int = 20, offset: int = 0, + musicFolderId: str = None, genre: str = None, + fromYear: int = None, toYear: int = None) -> List[Album]: + """ + Get a list of albums by criteria (newest, frequent, recent, random, highest, alphabetical). + + Args: + listType: Album list type (e.g., 'newest', 'frequent', 'recent', 'random', 'highest') + size: Number of albums to return + offset: Offset for pagination + musicFolderId: Optional folder filter + genre: Optional genre filter + fromYear: Optional starting year + toYear: Optional ending year + + Returns: + List of Album objects + """ + params = { + 'type': listType, + 'size': size, + 'offset': offset + } + if musicFolderId: + params['musicFolderId'] = musicFolderId + if genre: + params['genre'] = genre + if fromYear: + params['fromYear'] = fromYear + if toYear: + params['toYear'] = toYear + + response = self._makeRequest('getAlbumList2', params) + albumsData = response.get('albumList2', {}) + return [Album.fromDict(a) for a in albumsData.get('album', [])] + def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]: """ Get songs by genre @@ -518,6 +554,52 @@ class SubsonicClient: 'songs': [Song.fromDict(s) for s in starredData.get('song', [])] } + # ==================== Recommendations ==================== + + def getTopSongs(self, artist: str, count: int = 50) -> List[Song]: + """ + Get top songs for an artist + + Args: + artist: Artist name + count: Number of songs to return + + Returns: + List of Song objects + """ + if not artist: + return [] + params = {'artist': artist, 'count': count} + response = self._makeRequest('getTopSongs', params) + topData = response.get('topSongs', {}) + return [Song.fromDict(s) for s in topData.get('song', [])] + + def getSimilarSongs(self, songId: str, count: int = 50) -> List[Song]: + """ + Get songs similar to a given song + + Args: + songId: ID of the seed song + count: Number of songs to return + """ + if not songId: + return [] + params = {'id': songId, 'count': count} + response = self._makeRequest('getSimilarSongs2', params) + data = response.get('similarSongs2', {}) + return [Song.fromDict(s) for s in data.get('song', [])] + + def getNowPlaying(self) -> List[Song]: + """ + Get songs currently being played by all users + + Returns: + List of Song objects + """ + response = self._makeRequest('getNowPlaying') + nowPlayingData = response.get('nowPlaying', {}) + return [Song.fromDict(s) for s in nowPlayingData.get('entry', [])] + # ==================== Lyrics ==================== def getLyrics(self, artist: str = None, title: str = None) -> Optional[str]: diff --git a/src/config/settings.py b/src/config/settings.py index 6ad0540..89d9078 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -4,6 +4,7 @@ Configuration management with XDG compliance import os import json +import tempfile from pathlib import Path from typing import Optional, Dict, Any @@ -106,16 +107,14 @@ class Settings: def save(self) -> None: """Save settings to file""" try: - with open(self.configFile, 'w', encoding='utf-8') as f: - json.dump(self._settings, f, indent=2) + self._write_secure_json(self.configFile, self._settings) except IOError as e: print(f"Failed to save settings: {e}") def saveServers(self) -> None: """Save server configurations to file""" try: - with open(self.serversFile, 'w', encoding='utf-8') as f: - json.dump(self._servers, f, indent=2) + self._write_secure_json(self.serversFile, self._servers) except IOError as e: print(f"Failed to save servers: {e}") @@ -166,3 +165,32 @@ class Settings: def hasServers(self) -> bool: """Check if any servers are configured""" return len(self._servers) > 0 + + def _write_secure_json(self, path: Path, payload: Any) -> None: + """ + Write JSON atomically with user-only permissions to avoid leaking credentials. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tempFile: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + 'w', + encoding='utf-8', + dir=path.parent, + prefix=path.name, + suffix='.tmp', + delete=False + ) as tmp: + tempFile = Path(tmp.name) + os.chmod(tempFile, 0o600) + json.dump(payload, tmp, indent=2) + tmp.flush() + os.fsync(tmp.fileno()) + os.replace(tempFile, path) + os.chmod(path, 0o600) + finally: + if tempFile and tempFile.exists(): + try: + tempFile.unlink() + except Exception: + pass diff --git a/src/main_window.py b/src/main_window.py index 3480aca..27a13b0 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -3,11 +3,12 @@ from __future__ import annotations from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from PySide6.QtWidgets import ( QHBoxLayout, QLabel, + QMenu, QListWidget, QListWidgetItem, QMainWindow, @@ -19,7 +20,7 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) -from PySide6.QtCore import Qt +from PySide6.QtCore import QPoint, Qt from PySide6.QtGui import ( QAction, QAccessible, @@ -37,6 +38,7 @@ from src.config.settings import Settings from src.managers.playback_manager import PlaybackManager from src.integrations.mpris import MprisService from src.widgets.accessible_text_dialog import AccessibleTextDialog +from src.widgets.add_to_playlist_dialog import AddToPlaylistDialog from src.widgets.search_dialog import SearchDialog from src.widgets.server_dialog import ServerDialog @@ -225,6 +227,7 @@ class MainWindow(QMainWindow): self.libraryTree.setAccessibleName("Library Tree") self.libraryTree.setRootIsDecorated(True) self.libraryTree.setItemsExpandable(True) + self.libraryTree.setContextMenuPolicy(Qt.CustomContextMenu) self.libraryTree.pageStep = self.pageStep self.libraryTree.setEnabled(False) leftLayout.addWidget(self.libraryTree) @@ -333,6 +336,7 @@ class MainWindow(QMainWindow): """Connect signals after widgets exist.""" self.libraryTree.itemExpanded.connect(self.onLibraryExpanded) self.libraryTree.itemActivated.connect(self.onLibraryActivated) + self.libraryTree.customContextMenuRequested.connect(self.onLibraryContextMenu) self.playButton.clicked.connect(self.playPauseAction.trigger) self.prevButton.clicked.connect(self.previousAction.trigger) @@ -501,6 +505,18 @@ class MainWindow(QMainWindow): self.artistsRoot.setExpanded(True) self.libraryTree.addTopLevelItem(self.artistsRoot) + self.favoritesRoot = QTreeWidgetItem(["Favorites"]) + self.favoritesRoot.setData(0, Qt.UserRole, {"type": "favorites_root", "loaded": True}) + self.favoritesRoot.setExpanded(True) + self.libraryTree.addTopLevelItem(self.favoritesRoot) + self.loadFavorites() + + self.discoverRoot = QTreeWidgetItem(["Discover"]) + self.discoverRoot.setData(0, Qt.UserRole, {"type": "discover_root", "loaded": True}) + self.discoverRoot.setExpanded(True) + self.libraryTree.addTopLevelItem(self.discoverRoot) + self.loadDiscoverSections() + self.playlistsRoot = QTreeWidgetItem(["Playlists"]) self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True}) self.playlistsRoot.setExpanded(True) @@ -557,6 +573,49 @@ 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", []) + self.favoritesRoot.takeChildren() + if not songs: + self.favoritesRoot.addChild(QTreeWidgetItem(["No favorites yet."])) + return + for song in songs: + label = self.formatSongLabel(song, includeAlbum=True) + item = QTreeWidgetItem([label]) + item.setData(0, Qt.UserRole, {"type": "song", "song": song}) + item.setData(0, Qt.AccessibleTextRole, label) + self.favoritesRoot.addChild(item) + + def loadDiscoverSections(self): + """Create discover sections for album lists and recommendations.""" + sections = [ + ("Recently Added", "newest"), + ("Frequently Played", "frequent"), + ("Recently Played", "recent"), + ("Random Albums", "random"), + ] + + for label, listType in sections: + item = QTreeWidgetItem([label]) + item.setData(0, Qt.UserRole, {"type": "album_list", "listType": listType, "loaded": False}) + item.setData(0, Qt.AccessibleTextRole, label) + item.addChild(QTreeWidgetItem(["Loading albums..."])) + self.discoverRoot.addChild(item) + + nowPlaying = QTreeWidgetItem(["Now Playing (server)"]) + nowPlaying.setData(0, Qt.UserRole, {"type": "now_playing", "loaded": False}) + nowPlaying.setData(0, Qt.AccessibleTextRole, "Now Playing across users") + nowPlaying.addChild(QTreeWidgetItem(["Loading songs..."])) + self.discoverRoot.addChild(nowPlaying) + + similarCurrent = QTreeWidgetItem(["Similar to Current Track"]) + similarCurrent.setData(0, Qt.UserRole, {"type": "similar_current", "loaded": False}) + similarCurrent.setData(0, Qt.AccessibleTextRole, "Songs similar to the current track") + similarCurrent.addChild(QTreeWidgetItem(["Load to view similar tracks..."])) + self.discoverRoot.addChild(similarCurrent) + def onLibraryExpanded(self, item: QTreeWidgetItem): """Lazy-load children when an item is expanded.""" data: Dict = item.data(0, Qt.UserRole) or {} @@ -574,6 +633,12 @@ class MainWindow(QMainWindow): 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: @@ -600,12 +665,77 @@ class MainWindow(QMainWindow): self.playArtist(data.get("id"), item) elif itemType == "artists_root": self.playAllArtists() + elif itemType == "favorites_root": + self.playFavorites() elif itemType == "genre": self.playGenre(data.get("name"), item) + elif itemType in ("album_list", "now_playing", "similar_current"): + item.setExpanded(not item.isExpanded()) else: # Toggle expansion for other nodes item.setExpanded(not item.isExpanded()) + def onLibraryContextMenu(self, position: QPoint): + """Show a context menu for the library tree (mouse or keyboard).""" + item = self.libraryTree.itemAt(position) + if not item: + return + + # Ensure actions target the item the user invoked the menu on + self.libraryTree.setCurrentItem(item) + + data: Dict = item.data(0, Qt.UserRole) or {} + itemType = data.get("type") + hasClient = self.client is not None + + menu = QMenu(self) + menu.setAccessibleName("Library Context Menu") + + if itemType == "song": + song: Optional[Song] = data.get("song") + menu.addAction("Play", lambda _=False, song=song: self.playback.enqueue(song, playNow=True) if song else None).setEnabled(hasClient and song is not None) + menu.addAction("Add to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient and song is not None) + menu.addAction("Add to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient and song is not None) + menu.addSeparator() + menu.addAction("Favorite", self.favoriteSelection).setEnabled(hasClient) + menu.addAction("Unfavorite", self.unfavoriteSelection).setEnabled(hasClient) + elif itemType == "album": + menu.addAction("Play Album", lambda _=False, item=item, data=data: self.playAlbum(data.get("id"), item)).setEnabled(hasClient) + menu.addAction("Add Album to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) + menu.addAction("Add Album to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) + menu.addSeparator() + menu.addAction("Favorite Album", self.favoriteSelection).setEnabled(hasClient) + menu.addAction("Unfavorite Album", self.unfavoriteSelection).setEnabled(hasClient) + elif itemType == "artist": + menu.addAction("Play Artist", lambda _=False, item=item, data=data: self.playArtist(data.get("id"), item)).setEnabled(hasClient) + menu.addAction("Add Artist to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) + menu.addAction("Add Artist to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) + menu.addSeparator() + menu.addAction("Favorite Artist", self.favoriteSelection).setEnabled(hasClient) + menu.addAction("Unfavorite Artist", self.unfavoriteSelection).setEnabled(hasClient) + elif itemType == "playlist": + menu.addAction("Play Playlist", lambda _=False, item=item, data=data: self.playPlaylist(data.get("id"), item)).setEnabled(hasClient) + menu.addAction("Add Playlist to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) + menu.addAction("Add Playlist to Another Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) + elif itemType == "genre": + menu.addAction("Play Genre", lambda _=False, item=item, data=data: self.playGenre(data.get("name"), item)).setEnabled(hasClient) + menu.addAction("Add Genre to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) + menu.addAction("Add Genre to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) + elif itemType == "artists_root": + menu.addAction("Play All Artists", self.playAllArtists).setEnabled(hasClient) + menu.addAction("Add All Artists to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) + menu.addAction("Add All Artists to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) + elif itemType == "favorites_root": + menu.addAction("Play Favorites", self.playFavorites).setEnabled(hasClient) + menu.addAction("Add Favorites to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) + menu.addAction("Add Favorites to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) + + if not menu.actions(): + return + + globalPos = self.libraryTree.viewport().mapToGlobal(position) + menu.exec(globalPos) + def populateArtistAlbums(self, parentItem: QTreeWidgetItem, artistId: str): """Add albums for a given artist.""" result = self.client.getArtist(artistId) @@ -651,6 +781,50 @@ class MainWindow(QMainWindow): parentItem.addChild(songItem) parentItem.setData(0, Qt.UserRole, {"type": "genre", "name": genreName, "loaded": True}) + def populateAlbumListSection(self, parentItem: QTreeWidgetItem, listType: str): + """Populate a discover album list section.""" + 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]) + albumItem.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False}) + albumItem.setData(0, Qt.AccessibleTextRole, label) + albumItem.addChild(QTreeWidgetItem(["Loading tracks..."])) + parentItem.addChild(albumItem) + + def populateNowPlayingSection(self, parentItem: QTreeWidgetItem): + """Populate songs currently playing across users.""" + parentItem.takeChildren() + songs = self.client.getNowPlaying() + if not songs: + parentItem.addChild(QTreeWidgetItem(["Nothing is currently playing."])) + return + 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) + + def populateSimilarToCurrent(self, parentItem: QTreeWidgetItem): + """Populate songs similar to the current track.""" + 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 + 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) + @staticmethod def formatSongLabel(song: Song, includeAlbum: bool = False) -> str: """Create a descriptive label for a song.""" @@ -687,6 +861,132 @@ class MainWindow(QMainWindow): data["loaded"] = True parentItem.setData(0, Qt.UserRole, data) + def addItemToQueue(self, item: QTreeWidgetItem): + """Append the item's songs to the queue without interrupting playback.""" + if not self.client: + self.announceLive("Not connected to a server.") + return + + data: Dict = item.data(0, Qt.UserRole) or {} + itemType = data.get("type") + + if itemType == "song": + song: Optional[Song] = data.get("song") + if not song: + self.announceLive("No song selected.") + return + self.playback.enqueue(song, playNow=False) + self.statusBar.showMessage(f"Added {song.title} to the queue", 3000) + 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) + + 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}") + return + + playlists = self.client.getPlaylists() + dialog = AddToPlaylistDialog(playlists, parent=self) + if not dialog.exec(): + return + + 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.""" + data: Dict = item.data(0, Qt.UserRole) or {} + itemType = data.get("type") + label = item.text(0) + + if not self.client: + return [], label + + try: + if itemType == "song": + song: Optional[Song] = data.get("song") + return [song] if song else [], self.formatSongLabel(song, includeAlbum=True) if song else label + 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 + if itemType == "artist": + artistData = self.client.getArtist(data.get("id")) + 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", [])) + return songs, artist.name if artist else label + if itemType == "playlist": + playlist: Playlist = self.client.getPlaylist(data.get("id")) + return playlist.songs, playlist.name + if itemType == "genre": + genreName = data.get("name") + songs = self.client.getSongsByGenre(genreName, count=5000) + return songs, genreName or label + if itemType == "artists_root": + 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", [])) + return songs, "all artists" + 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 + if itemType == "album_list": + # These nodes contain album children; we don't aggregate here + return [], label + if itemType == "now_playing": + songs = self.client.getNowPlaying() + return songs, "now playing" + if itemType == "similar_current": + current = self.playback.currentSong() + songs = self.client.getSimilarSongs(current.id, count=40) if current else [] + return songs, "similar tracks" + except Exception as e: + AccessibleTextDialog.showError("Queue Error", str(e), parent=self) + return [], label + # ============ Search ============ def openSearchDialog(self): @@ -852,6 +1152,18 @@ class MainWindow(QMainWindow): except Exception as e: AccessibleTextDialog.showError("Playlist Error", str(e), 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) + def onTrackChanged(self, song: Song): """Update UI when track changes.""" self.nowPlayingInfo.setText(f"{song.title} — {song.artist} ({song.album})") diff --git a/src/widgets/add_to_playlist_dialog.py b/src/widgets/add_to_playlist_dialog.py new file mode 100644 index 0000000..7f40d65 --- /dev/null +++ b/src/widgets/add_to_playlist_dialog.py @@ -0,0 +1,106 @@ +"""Dialog to select an existing playlist or create a new one.""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, + QComboBox, +) + +from src.api.models import Playlist + + +class AddToPlaylistDialog(QDialog): + """Accessible dialog for choosing or creating a playlist.""" + + def __init__(self, playlists: List[Playlist], parent=None): + super().__init__(parent) + self.playlists = playlists + self.setWindowTitle("Add to Playlist") + self.setMinimumWidth(360) + self.selectedPlaylistId: Optional[str] = None + self.newPlaylistName: Optional[str] = None + self.setupUi() + + def setupUi(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Choose an existing playlist or create a new one.")) + + form = QFormLayout() + + self.playlistCombo = QComboBox() + self.playlistCombo.setAccessibleName("Existing Playlists") + self.playlistCombo.addItem("Create new playlist...", userData=None) + for pl in self.playlists: + self.playlistCombo.addItem(pl.name, userData=pl.id) + self.playlistCombo.currentIndexChanged.connect(self.onPlaylistChanged) + form.addRow("Playlist:", self.playlistCombo) + + nameRow = QHBoxLayout() + self.nameEdit = QLineEdit() + self.nameEdit.setAccessibleName("New Playlist Name") + self.nameEdit.setPlaceholderText("Enter a new playlist name") + self.nameEdit.textChanged.connect(self.validate) + nameRow.addWidget(self.nameEdit) + form.addRow("Name:", nameRow) + + layout.addLayout(form) + + self.statusLabel = QLabel("") + self.statusLabel.setAccessibleName("Playlist Status") + layout.addWidget(self.statusLabel) + + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + layout.addWidget(self.buttons) + + self.onPlaylistChanged(0) + self.validate() + + def onPlaylistChanged(self, index: int): + """Toggle name input based on selection.""" + playlistId = self.playlistCombo.itemData(index) + if playlistId: + self.nameEdit.setEnabled(False) + self.nameEdit.clear() + else: + self.nameEdit.setEnabled(True) + self.validate() + + def validate(self): + """Enable OK only when selection is valid.""" + playlistId = self.playlistCombo.currentData() + if playlistId: + self.statusLabel.setText("") + self.buttons.button(QDialogButtonBox.Ok).setEnabled(True) + return + + name = self.nameEdit.text().strip() + if not name: + self.statusLabel.setText("Enter a playlist name.") + self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) + else: + self.statusLabel.setText("") + self.buttons.button(QDialogButtonBox.Ok).setEnabled(True) + + def getResult(self) -> Tuple[str, Optional[str]]: + """ + Returns: + ('existing', playlistId) or ('new', name) + """ + playlistId = self.playlistCombo.currentData() + if playlistId: + return ("existing", playlistId) + return ("new", self.nameEdit.text().strip()) diff --git a/src/widgets/search_dialog.py b/src/widgets/search_dialog.py index 0735c60..e446f53 100644 --- a/src/widgets/search_dialog.py +++ b/src/widgets/search_dialog.py @@ -4,28 +4,30 @@ Accessible search dialog for finding tracks quickly. from __future__ import annotations -from typing import List +from typing import List, Dict, Optional -from PySide6.QtCore import Signal, Qt +from PySide6.QtCore import QPoint, Signal, Qt from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QHBoxLayout, QLabel, + QComboBox, QLineEdit, - QListWidget, - QListWidgetItem, + QMenu, QPushButton, + QTreeWidgetItem, QVBoxLayout, ) from src.api.client import SubsonicClient -from src.api.models import Song +from src.api.models import Album, Artist, Song +from src.accessibility.accessible_tree import AccessibleTreeWidget from src.widgets.accessible_text_dialog import AccessibleTextDialog class SearchDialog(QDialog): - """Simple dialog for searching the Navidrome library.""" + """Search dialog that mirrors the library tree interactions.""" songActivated = Signal(Song) @@ -35,16 +37,27 @@ class SearchDialog(QDialog): self.setWindowTitle("Search Library") self.setMinimumSize(500, 420) self.setupUi() + self._resultsLoaded = False + self.setModal(True) def setupUi(self): layout = QVBoxLayout(self) - self.instructions = QLabel("Enter a song, artist, or album title. Press Enter to play results.") + self.instructions = QLabel( + "Search songs, artists, albums, or genres. Results behave like the main library tree: " + "expand with arrow keys, press Enter to play, or open the context menu with right click or Shift+F10." + ) self.instructions.setAccessibleName("Search Instructions") self.instructions.setWordWrap(True) layout.addWidget(self.instructions) inputLayout = QHBoxLayout() + + self.filterSelect = QComboBox() + self.filterSelect.setAccessibleName("Search Type") + self.filterSelect.addItems(["All", "Songs", "Artists", "Albums", "Genres"]) + inputLayout.addWidget(self.filterSelect) + self.queryEdit = QLineEdit() self.queryEdit.setAccessibleName("Search Query") self.queryEdit.returnPressed.connect(self.runSearch) @@ -57,10 +70,15 @@ class SearchDialog(QDialog): layout.addLayout(inputLayout) - self.resultsList = QListWidget() - self.resultsList.setAccessibleName("Search Results") - self.resultsList.itemActivated.connect(self.activateSelection) - layout.addWidget(self.resultsList) + self.resultsTree = AccessibleTreeWidget() + self.resultsTree.setAccessibleName("Search Results Tree") + self.resultsTree.setHeaderHidden(True) + self.resultsTree.setRootIsDecorated(True) + self.resultsTree.setContextMenuPolicy(Qt.CustomContextMenu) + self.resultsTree.itemActivated.connect(self.handleActivate) + self.resultsTree.itemExpanded.connect(self.onItemExpanded) + self.resultsTree.customContextMenuRequested.connect(self.showContextMenu) + layout.addWidget(self.resultsTree) self.statusLabel = QLabel("") self.statusLabel.setAccessibleName("Search Status") @@ -78,31 +96,237 @@ class SearchDialog(QDialog): return self.statusLabel.setText("Searching...") - self.resultsList.clear() + self.resultsTree.clear() + self._resultsLoaded = False + try: - results = self.client.search(query, artistCount=0, albumCount=0, songCount=50) - songs: List[Song] = results.get("songs", []) - if not songs: - self.statusLabel.setText("No songs found.") - return + mode = self.filterSelect.currentText() + if mode == "Genres": + songs = self.client.getSongsByGenre(query, count=200) + self.populateSongResults(songs, f"Songs in genre '{query}'") + else: + artistCount = 40 if mode in ("All", "Artists") else 0 + albumCount = 40 if mode in ("All", "Albums") else 0 + songCount = 80 if mode in ("All", "Songs") else 40 + results = self.client.search(query, artistCount=artistCount, albumCount=albumCount, songCount=songCount) + self.populateResults(results) - for song in songs: - text = f"{song.title} — {song.artist} ({song.album}, {song.durationFormatted})" - item = QListWidgetItem(text) - item.setData(Qt.UserRole, song) - item.setData(Qt.AccessibleTextRole, text) - self.resultsList.addItem(item) - - self.statusLabel.setText(f"Found {len(songs)} songs. Activate a result to play.") - self.resultsList.setCurrentRow(0) - self.resultsList.setFocus() + if self._resultsLoaded: + self.resultsTree.setFocus() + first = self.resultsTree.topLevelItem(0) + if first: + self.resultsTree.setCurrentItem(first) + self.statusLabel.setText("Search complete. Use the tree to play or queue items.") + else: + self.statusLabel.setText("No results found.") except Exception as e: AccessibleTextDialog.showError("Search Error", str(e), parent=self) self.statusLabel.setText("Search failed.") - def activateSelection(self, item: QListWidgetItem): - """Emit the chosen song and close the dialog.""" - song = item.data(Qt.UserRole) - if isinstance(song, Song): - self.songActivated.emit(song) - self.accept() + def populateResults(self, results: Dict[str, List]): + """Populate the results tree from search3 data.""" + artists: List[Artist] = results.get("artists", []) + albums: List[Album] = results.get("albums", []) + songs: List[Song] = results.get("songs", []) + + if artists: + root = QTreeWidgetItem([f"Artists ({len(artists)})"]) + root.setData(0, Qt.UserRole, {"type": "artists_root"}) + root.setExpanded(True) + for artist in artists: + item = QTreeWidgetItem([artist.name]) + item.setData(0, Qt.UserRole, {"type": "artist", "id": artist.id, "artist": artist, "loaded": False}) + item.setData(0, Qt.AccessibleTextRole, f"{artist.name} ({artist.albumCount} albums)") + item.addChild(QTreeWidgetItem(["Loading..."])) + root.addChild(item) + self.resultsTree.addTopLevelItem(root) + self._resultsLoaded = True + + if albums: + root = QTreeWidgetItem([f"Albums ({len(albums)})"]) + root.setData(0, Qt.UserRole, {"type": "albums_root"}) + root.setExpanded(True) + for album in albums: + label = f"{album.name} — {album.artist} ({album.songCount} tracks)" + item = QTreeWidgetItem([label]) + item.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False}) + item.setData(0, Qt.AccessibleTextRole, label) + item.addChild(QTreeWidgetItem(["Loading..."])) + root.addChild(item) + self.resultsTree.addTopLevelItem(root) + self._resultsLoaded = True + + if songs: + self.populateSongResults(songs, f"Songs ({len(songs)})") + + def populateSongResults(self, songs: List[Song], rootLabel: str): + """Add song results under a root heading.""" + if not songs: + return + root = QTreeWidgetItem([rootLabel]) + root.setData(0, Qt.UserRole, {"type": "songs_root"}) + root.setExpanded(True) + for song in songs: + text = f"{song.title} — {song.artist} ({song.album}, {song.durationFormatted})" + item = QTreeWidgetItem([text]) + item.setData(0, Qt.UserRole, {"type": "song", "song": song}) + item.setData(0, Qt.AccessibleTextRole, text) + root.addChild(item) + self.resultsTree.addTopLevelItem(root) + self._resultsLoaded = True + + def handleActivate(self, item: QTreeWidgetItem): + """Activate the selected item similar to the main tree.""" + data: Dict = item.data(0, Qt.UserRole) or {} + itemType = data.get("type") + parentWindow = self.parent() + if itemType == "song": + song: Song = data.get("song") + if song: + if hasattr(parentWindow, "playback"): + parentWindow.playback.enqueue(song, playNow=True) + if hasattr(parentWindow, "enablePlaybackActions"): + parentWindow.enablePlaybackActions(True) + self.songActivated.emit(song) + self.accept() + elif itemType == "album": + if hasattr(parentWindow, "playAlbum"): + parentWindow.playAlbum(data.get("id")) + self.accept() + elif itemType == "artist": + if hasattr(parentWindow, "playArtist"): + parentWindow.playArtist(data.get("id")) + self.accept() + elif itemType == "artists_root": + if hasattr(parentWindow, "playAllArtists"): + parentWindow.playAllArtists() + self.accept() + + def onItemExpanded(self, item: QTreeWidgetItem): + """Lazy-load children for artist or album results.""" + data: Dict = item.data(0, Qt.UserRole) or {} + if data.get("loaded") or not self.client: + return + + try: + item.takeChildren() + itemType = data.get("type") + if itemType == "artist": + artistData = self.client.getArtist(data.get("id")) + for album in artistData.get("albums", []): + label = f"{album.name} ({album.songCount} tracks)" + albumItem = QTreeWidgetItem([label]) + albumItem.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False}) + albumItem.setData(0, Qt.AccessibleTextRole, label) + albumItem.addChild(QTreeWidgetItem(["Loading..."])) + item.addChild(albumItem) + elif itemType == "album": + albumData = self.client.getAlbum(data.get("id")) + for song in albumData.get("songs", []): + text = f"{song.title} — {song.artist} ({song.durationFormatted})" + child = QTreeWidgetItem([text]) + child.setData(0, Qt.UserRole, {"type": "song", "song": song}) + child.setData(0, Qt.AccessibleTextRole, text) + item.addChild(child) + data["loaded"] = True + item.setData(0, Qt.UserRole, data) + except Exception as e: + AccessibleTextDialog.showError("Search Error", str(e), parent=self) + + def showContextMenu(self, position: QPoint): + """Show a context menu with play/queue/favorite actions for search results.""" + item = self.resultsTree.itemAt(position) + if not item: + return + self.resultsTree.setCurrentItem(item) + parentWindow = self.parent() + hasClient = isinstance(parentWindow, object) and hasattr(parentWindow, "client") and parentWindow.client + hasPlayback = hasattr(parentWindow, "playback") + menu = QMenu(self) + menu.setAccessibleName("Search Context Menu") + data: Dict = item.data(0, Qt.UserRole) or {} + itemType = data.get("type") + + if itemType == "song": + song: Optional[Song] = data.get("song") + if song and hasClient and hasPlayback: + menu.addAction("Play", lambda: parentWindow.playback.enqueue(song, playNow=True)) + menu.addAction("Add to Queue", lambda: parentWindow.playback.enqueue(song, playNow=False)) + if hasattr(parentWindow, "addItemToPlaylist"): + menu.addAction("Add to Playlist", lambda: parentWindow.addItemToPlaylist(item)) + menu.addSeparator() + menu.addAction("Favorite", lambda: self.favoriteItem(item, "favorite")) + menu.addAction("Unfavorite", lambda: self.favoriteItem(item, "unfavorite")) + elif itemType == "album" and hasClient: + menu.addAction("Play Album", lambda: parentWindow.playAlbum(data.get("id"))) + menu.addAction("Add Album to Queue", lambda: self.enqueueCollection(parentWindow, item)) + if hasattr(parentWindow, "addItemToPlaylist"): + menu.addAction("Add Album to Playlist", lambda: parentWindow.addItemToPlaylist(item)) + menu.addSeparator() + menu.addAction("Favorite Album", lambda: self.favoriteItem(item, "favorite")) + menu.addAction("Unfavorite Album", lambda: self.favoriteItem(item, "unfavorite")) + elif itemType == "artist" and hasClient: + menu.addAction("Play Artist", lambda: parentWindow.playArtist(data.get("id"))) + menu.addAction("Add Artist to Queue", lambda: self.enqueueCollection(parentWindow, item)) + if hasattr(parentWindow, "addItemToPlaylist"): + menu.addAction("Add Artist to Playlist", lambda: parentWindow.addItemToPlaylist(item)) + menu.addSeparator() + menu.addAction("Favorite Artist", lambda: self.favoriteItem(item, "favorite")) + menu.addAction("Unfavorite Artist", lambda: self.favoriteItem(item, "unfavorite")) + + if not menu.actions(): + return + + globalPos = self.resultsTree.viewport().mapToGlobal(position) + menu.exec(globalPos) + + def enqueueCollection(self, parentWindow, item: QTreeWidgetItem): + """Queue albums/artists from search results via parent helpers when possible.""" + if hasattr(parentWindow, "addItemToQueue"): + parentWindow.addItemToQueue(item) + + def favoriteItem(self, item: QTreeWidgetItem, action: str): + """Favorite or unfavorite an item directly via the client.""" + data: Dict = item.data(0, Qt.UserRole) or {} + itemType = data.get("type") + parentWindow = self.parent() + + targetType = None + targetId = None + label = item.text(0) + + if itemType == "song": + song: Optional[Song] = data.get("song") + if song: + targetType = "song" + targetId = song.id + label = f"{song.title} by {song.artist}" + elif itemType == "album": + album: Optional[Album] = data.get("album") + targetType = "album" + targetId = data.get("id") + if album: + label = album.name + elif itemType == "artist": + artist: Optional[Artist] = data.get("artist") + targetType = "artist" + targetId = data.get("id") + if artist: + label = artist.name + + if not targetType or not targetId or not self.client: + return + + try: + if action == "favorite": + self.client.star(targetId, targetType) + message = f"{label} added to favorites" + else: + self.client.unstar(targetId, targetType) + message = f"{label} removed from favorites" + if hasattr(parentWindow, "announceLive"): + parentWindow.announceLive(message) + elif self.statusLabel: + self.statusLabel.setText(message) + except Exception as e: + AccessibleTextDialog.showError("Favorite Error", str(e), parent=self)