Add server management

This commit is contained in:
Storm Dragon
2026-09-17 12:39:15 -04:00
parent 3ab834d00c
commit 47854c46ec
5 changed files with 359 additions and 7 deletions
+2
View File
@@ -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]:
+40 -7
View File
@@ -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(
+2
View File
@@ -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()
+178
View File
@@ -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()
+137
View File
@@ -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"}