More functionality added included playlist management.
This commit is contained in:
11
README.md
11
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`
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})")
|
||||
|
||||
106
src/widgets/add_to_playlist_dialog.py
Normal file
106
src/widgets/add_to_playlist_dialog.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user