348 lines
15 KiB
Python
348 lines
15 KiB
Python
"""
|
|
Accessible search dialog for finding tracks quickly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import List, Dict, Optional
|
|
|
|
from PySide6.QtCore import QPoint, Signal, Qt
|
|
from PySide6.QtWidgets import (
|
|
QDialog,
|
|
QDialogButtonBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QComboBox,
|
|
QLineEdit,
|
|
QMenu,
|
|
QPushButton,
|
|
QTreeWidgetItem,
|
|
QVBoxLayout,
|
|
QAbstractItemView,
|
|
)
|
|
|
|
from src.api.client import SubsonicClient
|
|
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):
|
|
"""Search dialog that mirrors the library tree interactions."""
|
|
|
|
songActivated = Signal(Song)
|
|
|
|
def __init__(self, client: SubsonicClient, parent=None):
|
|
super().__init__(parent)
|
|
self.client = client
|
|
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(
|
|
"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)
|
|
inputLayout.addWidget(self.queryEdit)
|
|
|
|
self.searchButton = QPushButton("&Search")
|
|
self.searchButton.setAccessibleName("Start Search")
|
|
self.searchButton.clicked.connect(self.runSearch)
|
|
inputLayout.addWidget(self.searchButton)
|
|
|
|
layout.addLayout(inputLayout)
|
|
|
|
self.resultsTree = AccessibleTreeWidget()
|
|
self.resultsTree.setAccessibleName("Search Results Tree")
|
|
self.resultsTree.setHeaderHidden(True)
|
|
self.resultsTree.setRootIsDecorated(True)
|
|
self.resultsTree.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.resultsTree.setUniformRowHeights(True)
|
|
self.resultsTree.setItemsExpandable(True)
|
|
self.resultsTree.setExpandsOnDoubleClick(True)
|
|
self.resultsTree.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
self.resultsTree.setAllColumnsShowFocus(True)
|
|
self.resultsTree.setFocusPolicy(Qt.StrongFocus)
|
|
self.resultsTree.itemActivated.connect(self.handleActivate)
|
|
self.resultsTree.itemExpanded.connect(self.onItemExpanded)
|
|
self.resultsTree.customContextMenuRequested.connect(self.showContextMenu)
|
|
layout.addWidget(self.resultsTree)
|
|
|
|
self.statusLabel = QLabel("")
|
|
self.statusLabel.setAccessibleName("Search Status")
|
|
layout.addWidget(self.statusLabel)
|
|
|
|
buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
|
|
buttonBox.rejected.connect(self.reject)
|
|
layout.addWidget(buttonBox)
|
|
|
|
def runSearch(self):
|
|
"""Run the Subsonic search and populate results."""
|
|
query = self.queryEdit.text().strip()
|
|
if not query:
|
|
self.statusLabel.setText("Enter a search term to begin.")
|
|
return
|
|
|
|
self.statusLabel.setText("Searching...")
|
|
self.resultsTree.clear()
|
|
self._resultsLoaded = False
|
|
|
|
try:
|
|
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)
|
|
|
|
if self._resultsLoaded:
|
|
self._focusFirstResult()
|
|
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 _focusFirstResult(self):
|
|
"""Move focus to the first result item for immediate keyboard navigation."""
|
|
self.resultsTree.setFocus(Qt.OtherFocusReason)
|
|
first = self.resultsTree.topLevelItem(0)
|
|
if not first:
|
|
return
|
|
target = first.child(0) if first.childCount() else first
|
|
self.resultsTree.setCurrentItem(target)
|
|
|
|
def populateResults(self, results: Dict[str, List]):
|
|
"""Populate the results tree from search3 data."""
|
|
artists: List[Artist] = results.get("artists", [])
|
|
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", "loaded": 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.resultsTree.expandItem(root)
|
|
self._resultsLoaded = True
|
|
|
|
if albums:
|
|
root = QTreeWidgetItem([f"Albums ({len(albums)})"])
|
|
root.setData(0, Qt.UserRole, {"type": "albums_root", "loaded": True})
|
|
for album in albums:
|
|
label = f"{album.name} — {album.artist} ({album.songCount} tracks)"
|
|
item = QTreeWidgetItem([label])
|
|
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.resultsTree.expandItem(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", "loaded": 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
|
|
self.resultsTree.expandItem(root)
|
|
|
|
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)
|
|
# Update accessibility for newly added children
|
|
self.resultsTree.updateChildAccessibility(item, True)
|
|
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)
|