Add server management
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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