More functionality added included playlist management.

This commit is contained in:
Storm Dragon
2025-12-15 22:25:07 -05:00
parent 555ca0bba9
commit 5507aa8faa
6 changed files with 800 additions and 41 deletions

View File

@@ -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`

View File

@@ -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]:

View File

@@ -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

View File

@@ -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})")

View File

@@ -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())

View File

@@ -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)