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