Add server management
This commit is contained in:
@@ -152,6 +152,8 @@ class Settings:
|
|||||||
"""Remove a server configuration"""
|
"""Remove a server configuration"""
|
||||||
if name in self._servers:
|
if name in self._servers:
|
||||||
del self._servers[name]
|
del self._servers[name]
|
||||||
|
if self.getDefaultServer() == name:
|
||||||
|
self.setDefaultServer(None)
|
||||||
self.saveServers()
|
self.saveServers()
|
||||||
|
|
||||||
def getDefaultServer(self) -> Optional[str]:
|
def getDefaultServer(self) -> Optional[str]:
|
||||||
|
|||||||
+40
-7
@@ -50,6 +50,7 @@ from src.widgets.accessible_text_dialog import AccessibleTextDialog
|
|||||||
from src.widgets.add_to_playlist_dialog import AddToPlaylistDialog
|
from src.widgets.add_to_playlist_dialog import AddToPlaylistDialog
|
||||||
from src.widgets.search_dialog import SearchDialog
|
from src.widgets.search_dialog import SearchDialog
|
||||||
from src.widgets.server_dialog import ServerDialog
|
from src.widgets.server_dialog import ServerDialog
|
||||||
|
from src.widgets.server_management_dialog import ServerManagementDialog
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
@@ -63,6 +64,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.settings = Settings()
|
self.settings = Settings()
|
||||||
self.client: Optional[CachedClient] = None
|
self.client: Optional[CachedClient] = None
|
||||||
self._rawClient: Optional[SubsonicClient] = None
|
self._rawClient: Optional[SubsonicClient] = None
|
||||||
|
self.activeServerName: Optional[str] = None
|
||||||
self.playback = PlaybackManager(self)
|
self.playback = PlaybackManager(self)
|
||||||
self.mpris: Optional[MprisService] = None
|
self.mpris: Optional[MprisService] = None
|
||||||
self._executor = ThreadPoolExecutor(max_workers=4)
|
self._executor = ThreadPoolExecutor(max_workers=4)
|
||||||
@@ -469,14 +471,44 @@ class MainWindow(QMainWindow):
|
|||||||
# ============ Connection handling ============
|
# ============ Connection handling ============
|
||||||
|
|
||||||
def showServerSetup(self):
|
def showServerSetup(self):
|
||||||
"""Show the server setup dialog"""
|
"""Show the server management dialog"""
|
||||||
dialog = ServerDialog(self.settings, parent=self)
|
dialog = ServerManagementDialog(self.settings, parent=self)
|
||||||
if dialog.exec():
|
dialogAccepted = dialog.exec()
|
||||||
serverName = dialog.getServerName()
|
|
||||||
self.logger.info("Server setup completed, connecting to '%s'", serverName)
|
for removedServerName in dialog.removedServerNames:
|
||||||
self.connectToServer(serverName)
|
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:
|
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):
|
def connectToDefaultServer(self):
|
||||||
"""Connect to the default server"""
|
"""Connect to the default server"""
|
||||||
@@ -531,6 +563,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.libraryBrowser.clear()
|
self.libraryBrowser.clear()
|
||||||
self.libraryBrowser.setEnabled(False)
|
self.libraryBrowser.setEnabled(False)
|
||||||
self._rawClient = rawClient
|
self._rawClient = rawClient
|
||||||
|
self.activeServerName = serverName
|
||||||
|
|
||||||
# Wrap with caching layer
|
# Wrap with caching layer
|
||||||
self.client = CachedClient(
|
self.client = CachedClient(
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ class ServerDialog(QDialog):
|
|||||||
# Set as default if checked
|
# Set as default if checked
|
||||||
if self.defaultCheckbox.isChecked():
|
if self.defaultCheckbox.isChecked():
|
||||||
self.settings.setDefaultServer(name)
|
self.settings.setDefaultServer(name)
|
||||||
|
elif not self.isNew and self.settings.getDefaultServer() == self.editingServer:
|
||||||
|
self.settings.setDefaultServer(None)
|
||||||
|
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user