From 47854c46ec7ec497653c1791d5bb55b631de736b Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 17 Sep 2026 12:39:15 -0400 Subject: [PATCH] Add server management --- src/config/settings.py | 2 + src/main_window.py | 47 ++++++- src/widgets/server_dialog.py | 2 + src/widgets/server_management_dialog.py | 178 ++++++++++++++++++++++++ tests/test_server_management.py | 137 ++++++++++++++++++ 5 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 src/widgets/server_management_dialog.py create mode 100644 tests/test_server_management.py diff --git a/src/config/settings.py b/src/config/settings.py index 89d9078..258fdc3 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -152,6 +152,8 @@ class Settings: """Remove a server configuration""" if name in self._servers: del self._servers[name] + if self.getDefaultServer() == name: + self.setDefaultServer(None) self.saveServers() def getDefaultServer(self) -> Optional[str]: diff --git a/src/main_window.py b/src/main_window.py index 247118f..f7bde46 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -50,6 +50,7 @@ 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 +from src.widgets.server_management_dialog import ServerManagementDialog class MainWindow(QMainWindow): @@ -63,6 +64,7 @@ class MainWindow(QMainWindow): self.settings = Settings() self.client: Optional[CachedClient] = None self._rawClient: Optional[SubsonicClient] = None + self.activeServerName: Optional[str] = None self.playback = PlaybackManager(self) self.mpris: Optional[MprisService] = None self._executor = ThreadPoolExecutor(max_workers=4) @@ -469,14 +471,44 @@ class MainWindow(QMainWindow): # ============ Connection handling ============ def showServerSetup(self): - """Show the server setup dialog""" - dialog = ServerDialog(self.settings, parent=self) - if dialog.exec(): - serverName = dialog.getServerName() - self.logger.info("Server setup completed, connecting to '%s'", serverName) - self.connectToServer(serverName) + """Show the server management dialog""" + dialog = ServerManagementDialog(self.settings, parent=self) + dialogAccepted = dialog.exec() + + for removedServerName in dialog.removedServerNames: + if removedServerName == self.activeServerName: + self._handle_active_server_removed() + + if dialogAccepted: + serverName = dialog.selectedServerName + if serverName: + self.logger.info("Server selection completed, connecting to '%s'", serverName) + self.connectToServer(serverName) + elif self.activeServerName in dialog.editedServerNames: + self.logger.info("Reconnecting after editing active server '%s'", self.activeServerName) + self.connectToServer(self.activeServerName) else: - self.logger.info("Server setup dialog canceled") + self.logger.info("Server management dialog canceled") + + def _handle_active_server_removed(self): + """Disconnect safely after the active server is deleted.""" + if self.client: + self.client.shutdown() + self.client = None + self._rawClient = None + self.activeServerName = None + self._stopStatusAnnouncements() + self.playback.clearQueue() + self.playback.streamResolver = None + self.libraryBrowser.clear() + self.libraryBrowser.setEnabled(False) + self.refreshAction.setEnabled(False) + self.fullSyncAction.setEnabled(False) + self.searchAction.setEnabled(False) + self.enablePlaybackActions(False) + self.connectionStatus.setText("Not connected") + self.libraryStatus.setText("Connect to a server to browse your library") + self.announceLive("Server removed") def connectToDefaultServer(self): """Connect to the default server""" @@ -531,6 +563,7 @@ class MainWindow(QMainWindow): self.libraryBrowser.clear() self.libraryBrowser.setEnabled(False) self._rawClient = rawClient + self.activeServerName = serverName # Wrap with caching layer self.client = CachedClient( diff --git a/src/widgets/server_dialog.py b/src/widgets/server_dialog.py index 7facb43..9e6956c 100644 --- a/src/widgets/server_dialog.py +++ b/src/widgets/server_dialog.py @@ -198,6 +198,8 @@ class ServerDialog(QDialog): # Set as default if checked if self.defaultCheckbox.isChecked(): self.settings.setDefaultServer(name) + elif not self.isNew and self.settings.getDefaultServer() == self.editingServer: + self.settings.setDefaultServer(None) self.accept() diff --git a/src/widgets/server_management_dialog.py b/src/widgets/server_management_dialog.py new file mode 100644 index 0000000..30c3561 --- /dev/null +++ b/src/widgets/server_management_dialog.py @@ -0,0 +1,178 @@ +"""Accessible dialog for managing configured servers.""" + +from __future__ import annotations + +from typing import Optional, Set + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QVBoxLayout, +) + +from src.config.settings import Settings +from src.widgets.server_dialog import ServerDialog + + +class ServerManagementDialog(QDialog): + """Dialog for connecting, editing, and removing configured servers.""" + + def __init__(self, settings: Settings, parent=None): + super().__init__(parent) + self.settings = settings + self.selectedServerName: Optional[str] = None + self.editedServerNames: Set[str] = set() + self.removedServerNames: Set[str] = set() + + self.setWindowTitle("Manage Servers") + self.setModal(True) + self.setMinimumWidth(460) + + self.setupUi() + self.refreshServers() + if self.serverList.count(): + self.serverList.setFocus() + else: + self.addButton.setFocus() + + def setupUi(self): + """Build the accessible server management interface.""" + layout = QVBoxLayout(self) + + instructions = QLabel("Choose a configured server to connect, edit, or remove.") + instructions.setAccessibleName("Server Management Instructions") + layout.addWidget(instructions) + + contentLayout = QHBoxLayout() + self.serverList = QListWidget() + self.serverList.setAccessibleName("Configured Servers") + self.serverList.setAccessibleDescription("Servers available in NaviPy") + self.serverList.setSelectionMode(QListWidget.SingleSelection) + self.serverList.currentRowChanged.connect(self.updateSelectionButtons) + self.serverList.itemDoubleClicked.connect(self.connectToSelected) + contentLayout.addWidget(self.serverList) + + buttonLayout = QVBoxLayout() + + self.addButton = QPushButton("&Add Server...") + self.addButton.setAccessibleName("Add Server") + self.addButton.clicked.connect(self.addServer) + buttonLayout.addWidget(self.addButton) + + self.connectButton = QPushButton("&Connect") + self.connectButton.setAccessibleName("Connect to Selected Server") + self.connectButton.setDefault(True) + self.connectButton.clicked.connect(self.connectToSelected) + buttonLayout.addWidget(self.connectButton) + + self.editButton = QPushButton("&Edit...") + self.editButton.setAccessibleName("Edit Selected Server") + self.editButton.clicked.connect(self.editSelected) + buttonLayout.addWidget(self.editButton) + + self.removeButton = QPushButton("&Remove") + self.removeButton.setAccessibleName("Remove Selected Server") + self.removeButton.clicked.connect(self.removeSelected) + buttonLayout.addWidget(self.removeButton) + + self.closeButton = QPushButton("Close") + self.closeButton.setAccessibleName("Close Server Management") + self.closeButton.clicked.connect(self.reject) + buttonLayout.addWidget(self.closeButton) + buttonLayout.addStretch() + + contentLayout.addLayout(buttonLayout) + layout.addLayout(contentLayout) + + def refreshServers(self): + """Reload the server list while preserving a useful selection.""" + currentName = self.currentServerName() + servers = self.settings.getServers() + defaultServer = self.settings.getDefaultServer() + + self.serverList.clear() + for name in servers: + label = f"{name} (default)" if name == defaultServer else name + item = QListWidgetItem(label) + item.setData(Qt.UserRole, name) + item.setData(Qt.AccessibleTextRole, label) + self.serverList.addItem(item) + + rowCount = self.serverList.count() + rowToSelect = 0 + if currentName: + for row in range(rowCount): + item = self.serverList.item(row) + if item.data(Qt.UserRole) == currentName: + rowToSelect = row + break + if rowCount: + self.serverList.setCurrentRow(rowToSelect) + self.updateSelectionButtons() + + def currentServerName(self) -> Optional[str]: + """Return the name stored for the selected server.""" + item = self.serverList.currentItem() + if not item: + return None + return item.data(Qt.UserRole) + + def updateSelectionButtons(self): + """Keep selection actions in sync with the list.""" + hasSelection = self.serverList.currentItem() is not None + self.connectButton.setEnabled(hasSelection) + self.editButton.setEnabled(hasSelection) + self.removeButton.setEnabled(hasSelection) + + def addServer(self): + """Open the add-server dialog and connect to the new server.""" + dialog = ServerDialog(self.settings, parent=self) + if dialog.exec(): + self.selectedServerName = dialog.getServerName() + self.accept() + + def editSelected(self): + """Open the edit-server dialog for the selected server.""" + serverName = self.currentServerName() + if not serverName: + return + + dialog = ServerDialog(self.settings, serverName, parent=self) + if dialog.exec(): + self.editedServerNames.add(serverName) + self.refreshServers() + + def removeSelected(self): + """Remove the selected server after confirmation.""" + serverName = self.currentServerName() + if not serverName: + return + + answer = QMessageBox.question( + self, + "Remove Server", + f"Remove the server '{serverName}'?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + + self.settings.removeServer(serverName) + self.removedServerNames.add(serverName) + self.selectedServerName = None + self.refreshServers() + + def connectToSelected(self): + """Accept the dialog and ask the main window to connect.""" + serverName = self.currentServerName() + if not serverName: + return + self.selectedServerName = serverName + self.accept() diff --git a/tests/test_server_management.py b/tests/test_server_management.py new file mode 100644 index 0000000..176dbf5 --- /dev/null +++ b/tests/test_server_management.py @@ -0,0 +1,137 @@ +"""Tests for server configuration editing and removal.""" + +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import pytest +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication, QDialog, QMessageBox + +from src.config.settings import Settings +from src.widgets.server_dialog import ServerDialog +from src.widgets.server_management_dialog import ServerManagementDialog +import src.widgets.server_management_dialog as server_management_dialog_module + + +@pytest.fixture(autouse=True) +def qt_offscreen_env(monkeypatch, tmp_path): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + + +@pytest.fixture() +def qt_app(): + app = QApplication.instance() + if app: + return app + return QApplication([]) + + +def make_settings() -> Settings: + settings = Settings() + settings.addServer("Primary", "https://primary.example", "Username", "password1") + settings.addServer("Backup", "https://backup.example", "Username", "password2") + settings.setDefaultServer("Primary") + return settings + + +def test_remove_server_clears_deleted_default(tmp_path: Path): + settings = make_settings() + + settings.removeServer("Primary") + + assert settings.getDefaultServer() is None + assert list(settings.getServers()) == ["Backup"] + with open(settings.serversFile, "r", encoding="utf-8") as serversFile: + assert "Primary" not in serversFile.read() + + +def test_edit_server_can_clear_default(qt_app): + settings = make_settings() + dialog = ServerDialog(settings, "Primary") + + dialog.urlEdit.setText("https://changed.example") + dialog.usernameEdit.setText("NewUsername") + dialog.passwordEdit.setText("new-password") + dialog.defaultCheckbox.setChecked(False) + dialog.saveServer() + + assert dialog.result() == QDialog.Accepted + server = settings.getServer("Primary") + assert server["url"] == "https://changed.example" + assert server["username"] == "NewUsername" + assert server["password"] == "new-password" + assert settings.getDefaultServer() is None + + +def test_management_dialog_lists_default_and_selection_actions(qt_app): + settings = make_settings() + dialog = ServerManagementDialog(settings) + + assert dialog.serverList.count() == 2 + assert dialog.serverList.item(0).text() == "Primary (default)" + assert dialog.serverList.item(1).text() == "Backup" + assert dialog.connectButton.isEnabled() + assert dialog.editButton.isEnabled() + assert dialog.removeButton.isEnabled() + + +def test_management_dialog_with_no_servers_focuses_add(qt_app): + settings = Settings() + dialog = ServerManagementDialog(settings) + + assert dialog.serverList.count() == 0 + assert dialog.focusWidget() is dialog.addButton + assert not dialog.connectButton.isEnabled() + assert not dialog.editButton.isEnabled() + assert not dialog.removeButton.isEnabled() + + +def test_management_dialog_remove_confirmed_server(qt_app, monkeypatch): + settings = make_settings() + dialog = ServerManagementDialog(settings) + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) + + dialog.removeSelected() + + assert dialog.removedServerNames == {"Primary"} + assert "Primary" not in settings.getServers() + assert dialog.serverList.count() == 1 + assert dialog.serverList.currentItem().data(Qt.UserRole) == "Backup" + + +def test_management_dialog_remove_canceled_server(qt_app, monkeypatch): + settings = make_settings() + dialog = ServerManagementDialog(settings) + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.No) + + dialog.removeSelected() + + assert dialog.removedServerNames == set() + assert list(settings.getServers()) == ["Primary", "Backup"] + + +def test_management_dialog_edit_uses_selected_server(qt_app, monkeypatch): + settings = make_settings() + dialog = ServerManagementDialog(settings) + calls = [] + + class FakeServerDialog: + def __init__(self, settings, serverName=None, parent=None): + calls.append((settings, serverName, parent)) + + def exec(self): + return True + + monkeypatch.setattr(server_management_dialog_module, "ServerDialog", FakeServerDialog) + + dialog.editSelected() + + assert calls == [(settings, "Primary", dialog)] + assert dialog.editedServerNames == {"Primary"}