Improve server management.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||||
pkgname=navipy-git
|
pkgname=navipy-git
|
||||||
pkgver=r16.geb63cfe
|
pkgver=r17.g3ab834d
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='Accessible Subsonic desktop client tested with Navidrome'
|
pkgdesc='Accessible Subsonic desktop client tested with Navidrome'
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|||||||
@@ -133,11 +133,12 @@ class Settings:
|
|||||||
|
|
||||||
def getServers(self) -> Dict[str, Dict[str, str]]:
|
def getServers(self) -> Dict[str, Dict[str, str]]:
|
||||||
"""Get all server configurations"""
|
"""Get all server configurations"""
|
||||||
return self._servers
|
return {name: dict(server) for name, server in self._servers.items()}
|
||||||
|
|
||||||
def getServer(self, name: str) -> Optional[Dict[str, str]]:
|
def getServer(self, name: str) -> Optional[Dict[str, str]]:
|
||||||
"""Get a specific server configuration"""
|
"""Get a specific server configuration"""
|
||||||
return self._servers.get(name)
|
server = self._servers.get(name)
|
||||||
|
return dict(server) if server else None
|
||||||
|
|
||||||
def addServer(self, name: str, url: str, username: str, password: str) -> None:
|
def addServer(self, name: str, url: str, username: str, password: str) -> None:
|
||||||
"""Add or update a server configuration"""
|
"""Add or update a server configuration"""
|
||||||
|
|||||||
+14
-9
@@ -479,16 +479,21 @@ class MainWindow(QMainWindow):
|
|||||||
if removedServerName == self.activeServerName:
|
if removedServerName == self.activeServerName:
|
||||||
self._handle_active_server_removed()
|
self._handle_active_server_removed()
|
||||||
|
|
||||||
if dialogAccepted:
|
serverName = dialog.selectedServerName
|
||||||
serverName = dialog.selectedServerName
|
if serverName and serverName not in dialog.removedServerNames:
|
||||||
if serverName:
|
self.logger.info("Server selection completed, connecting to '%s'", serverName)
|
||||||
self.logger.info("Server selection completed, connecting to '%s'", serverName)
|
self.connectToServer(serverName)
|
||||||
self.connectToServer(serverName)
|
elif (
|
||||||
elif self.activeServerName in dialog.editedServerNames:
|
self.activeServerName in dialog.editedServerNames
|
||||||
self.logger.info("Reconnecting after editing active server '%s'", self.activeServerName)
|
and self.activeServerName not in dialog.removedServerNames
|
||||||
self.connectToServer(self.activeServerName)
|
):
|
||||||
|
self.logger.info("Reconnecting after editing active server '%s'", self.activeServerName)
|
||||||
|
self.connectToServer(self.activeServerName)
|
||||||
else:
|
else:
|
||||||
self.logger.info("Server management dialog canceled")
|
if dialogAccepted:
|
||||||
|
self.logger.info("Server management dialog closed without selecting a server")
|
||||||
|
else:
|
||||||
|
self.logger.info("Server management dialog canceled")
|
||||||
|
|
||||||
def _handle_active_server_removed(self):
|
def _handle_active_server_removed(self):
|
||||||
"""Disconnect safely after the active server is deleted."""
|
"""Disconnect safely after the active server is deleted."""
|
||||||
|
|||||||
+107
-13
@@ -2,19 +2,56 @@
|
|||||||
Server connection dialog for adding/editing Navidrome servers
|
Server connection dialog for adding/editing Navidrome servers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from PySide6.QtCore import QRunnable, QThreadPool, Signal
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||||
QLineEdit, QPushButton, QLabel, QMessageBox, QCheckBox
|
QLineEdit, QPushButton, QLabel, QMessageBox, QCheckBox
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt
|
|
||||||
|
|
||||||
from src.api.client import SubsonicClient, SubsonicError
|
from src.api.client import SubsonicClient, SubsonicError
|
||||||
from src.config.settings import Settings
|
from src.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class _ConnectionTestTask(QRunnable):
|
||||||
|
"""Run a connection test without blocking the UI thread."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, username: str, password: str, finished: Callable):
|
||||||
|
super().__init__()
|
||||||
|
self.url = url
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.finished = finished
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client = SubsonicClient(self.url, self.username, self.password)
|
||||||
|
succeeded = bool(client.ping())
|
||||||
|
if succeeded:
|
||||||
|
message = "Connection successful!"
|
||||||
|
else:
|
||||||
|
message = "Invalid response"
|
||||||
|
except SubsonicError as error:
|
||||||
|
succeeded = False
|
||||||
|
message = error.message
|
||||||
|
except Exception as error:
|
||||||
|
succeeded = False
|
||||||
|
message = str(error)
|
||||||
|
finally:
|
||||||
|
if client is not None:
|
||||||
|
client.session.close()
|
||||||
|
|
||||||
|
self.finished.emit(succeeded, message)
|
||||||
|
|
||||||
|
|
||||||
class ServerDialog(QDialog):
|
class ServerDialog(QDialog):
|
||||||
"""Dialog for configuring a Navidrome server connection"""
|
"""Dialog for configuring a Navidrome server connection"""
|
||||||
|
|
||||||
|
_testFinished = Signal(bool, str)
|
||||||
|
|
||||||
def __init__(self, settings: Settings, serverName: str = None, parent=None):
|
def __init__(self, settings: Settings, serverName: str = None, parent=None):
|
||||||
"""
|
"""
|
||||||
Initialize the server dialog
|
Initialize the server dialog
|
||||||
@@ -28,6 +65,8 @@ class ServerDialog(QDialog):
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.editingServer = serverName
|
self.editingServer = serverName
|
||||||
self.isNew = serverName is None
|
self.isNew = serverName is None
|
||||||
|
self._connectionTestRunning = False
|
||||||
|
self._testFinished.connect(self._handleConnectionTest)
|
||||||
|
|
||||||
self.setWindowTitle("Add Server" if self.isNew else "Edit Server")
|
self.setWindowTitle("Add Server" if self.isNew else "Edit Server")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
@@ -135,25 +174,72 @@ class ServerDialog(QDialog):
|
|||||||
username = self.usernameEdit.text().strip()
|
username = self.usernameEdit.text().strip()
|
||||||
password = self.passwordEdit.text()
|
password = self.passwordEdit.text()
|
||||||
|
|
||||||
if not url or not username or not password:
|
if not url:
|
||||||
self.statusLabel.setText("Please fill in all fields")
|
self.statusLabel.setText("Please fill in all fields")
|
||||||
|
self.urlEdit.setFocus()
|
||||||
|
return
|
||||||
|
if not self._isValidServerUrl(url):
|
||||||
|
self.statusLabel.setText(
|
||||||
|
"Enter a valid server URL beginning with http:// or https://"
|
||||||
|
)
|
||||||
|
self.urlEdit.setFocus()
|
||||||
|
return
|
||||||
|
if not username:
|
||||||
|
self.statusLabel.setText("Please enter a username")
|
||||||
|
self.usernameEdit.setFocus()
|
||||||
|
return
|
||||||
|
if not password:
|
||||||
|
self.statusLabel.setText("Please enter a password")
|
||||||
|
self.passwordEdit.setFocus()
|
||||||
|
return
|
||||||
|
if self._connectionTestRunning:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.statusLabel.setText("Testing connection...")
|
self.statusLabel.setText("Testing connection...")
|
||||||
self.testButton.setEnabled(False)
|
self.testButton.setEnabled(False)
|
||||||
|
|
||||||
|
self._connectionTestRunning = True
|
||||||
|
QThreadPool.globalInstance().start(
|
||||||
|
_ConnectionTestTask(url, username, password, self._testFinished)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handleConnectionTest(self, succeeded: bool, message: str):
|
||||||
|
"""Show the result after a background connection test finishes."""
|
||||||
|
self._connectionTestRunning = False
|
||||||
|
self.testButton.setEnabled(True)
|
||||||
|
if succeeded:
|
||||||
|
self.statusLabel.setText(message)
|
||||||
|
else:
|
||||||
|
self.statusLabel.setText(f"Connection failed: {message}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _isValidServerUrl(url: str) -> bool:
|
||||||
|
"""Require an HTTP or HTTPS URL with a host name."""
|
||||||
try:
|
try:
|
||||||
client = SubsonicClient(url, username, password)
|
parsedUrl = urlparse(url)
|
||||||
if client.ping():
|
except ValueError:
|
||||||
self.statusLabel.setText("Connection successful!")
|
return False
|
||||||
else:
|
return (
|
||||||
self.statusLabel.setText("Connection failed: Invalid response")
|
parsedUrl.scheme in ("http", "https")
|
||||||
except SubsonicError as e:
|
and bool(parsedUrl.hostname)
|
||||||
self.statusLabel.setText(f"Connection failed: {e.message}")
|
)
|
||||||
except Exception as e:
|
|
||||||
self.statusLabel.setText(f"Connection failed: {str(e)}")
|
def accept(self):
|
||||||
finally:
|
"""Disconnect test updates before the dialog closes."""
|
||||||
self.testButton.setEnabled(True)
|
self._disconnectConnectionTest()
|
||||||
|
super().accept()
|
||||||
|
|
||||||
|
def reject(self):
|
||||||
|
"""Disconnect test updates before the dialog closes."""
|
||||||
|
self._disconnectConnectionTest()
|
||||||
|
super().reject()
|
||||||
|
|
||||||
|
def _disconnectConnectionTest(self):
|
||||||
|
"""Stop queued test updates from reaching a closing dialog."""
|
||||||
|
try:
|
||||||
|
self._testFinished.disconnect(self._handleConnectionTest)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def saveServer(self):
|
def saveServer(self):
|
||||||
"""Save the server configuration"""
|
"""Save the server configuration"""
|
||||||
@@ -172,6 +258,14 @@ class ServerDialog(QDialog):
|
|||||||
QMessageBox.warning(self, "Validation Error", "Please enter the server URL")
|
QMessageBox.warning(self, "Validation Error", "Please enter the server URL")
|
||||||
self.urlEdit.setFocus()
|
self.urlEdit.setFocus()
|
||||||
return
|
return
|
||||||
|
if not self._isValidServerUrl(url):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Validation Error",
|
||||||
|
"Please enter a valid server URL beginning with http:// or https:// and including a host name"
|
||||||
|
)
|
||||||
|
self.urlEdit.setFocus()
|
||||||
|
return
|
||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
QMessageBox.warning(self, "Validation Error", "Please enter a username")
|
QMessageBox.warning(self, "Validation Error", "Please enter a username")
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class ServerManagementDialog(QDialog):
|
|||||||
|
|
||||||
self.settings.removeServer(serverName)
|
self.settings.removeServer(serverName)
|
||||||
self.removedServerNames.add(serverName)
|
self.removedServerNames.add(serverName)
|
||||||
|
self.editedServerNames.discard(serverName)
|
||||||
self.selectedServerName = None
|
self.selectedServerName = None
|
||||||
self.refreshServers()
|
self.refreshServers()
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from src.config.settings import Settings
|
|||||||
from src.widgets.server_dialog import ServerDialog
|
from src.widgets.server_dialog import ServerDialog
|
||||||
from src.widgets.server_management_dialog import ServerManagementDialog
|
from src.widgets.server_management_dialog import ServerManagementDialog
|
||||||
import src.widgets.server_management_dialog as server_management_dialog_module
|
import src.widgets.server_management_dialog as server_management_dialog_module
|
||||||
|
import src.widgets.server_dialog as server_dialog_module
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -70,6 +71,41 @@ def test_edit_server_can_clear_default(qt_app):
|
|||||||
assert settings.getDefaultServer() is None
|
assert settings.getDefaultServer() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_dialog_rejects_invalid_url(qt_app, monkeypatch):
|
||||||
|
settings = Settings()
|
||||||
|
dialog = ServerDialog(settings)
|
||||||
|
monkeypatch.setattr(QMessageBox, "warning", lambda *args, **kwargs: None)
|
||||||
|
|
||||||
|
dialog.nameEdit.setText("Primary")
|
||||||
|
dialog.urlEdit.setText("music.example.com")
|
||||||
|
dialog.usernameEdit.setText("Username")
|
||||||
|
dialog.passwordEdit.setText("password")
|
||||||
|
dialog.saveServer()
|
||||||
|
|
||||||
|
assert dialog.result() != QDialog.Accepted
|
||||||
|
assert settings.getServers() == {}
|
||||||
|
assert dialog.focusWidget() is dialog.urlEdit
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_dialog_test_rejects_invalid_url_without_network(qt_app, monkeypatch):
|
||||||
|
settings = Settings()
|
||||||
|
dialog = ServerDialog(settings)
|
||||||
|
|
||||||
|
def unexpected_client(*args, **kwargs):
|
||||||
|
raise AssertionError("Connection test should validate before creating a client")
|
||||||
|
|
||||||
|
monkeypatch.setattr(server_dialog_module, "SubsonicClient", unexpected_client)
|
||||||
|
dialog.urlEdit.setText("ftp://music.example.com")
|
||||||
|
dialog.usernameEdit.setText("Username")
|
||||||
|
dialog.passwordEdit.setText("password")
|
||||||
|
|
||||||
|
dialog.testConnection()
|
||||||
|
|
||||||
|
assert dialog.testButton.isEnabled()
|
||||||
|
assert dialog.statusLabel.text() == "Enter a valid server URL beginning with http:// or https://"
|
||||||
|
assert dialog.focusWidget() is dialog.urlEdit
|
||||||
|
|
||||||
|
|
||||||
def test_management_dialog_lists_default_and_selection_actions(qt_app):
|
def test_management_dialog_lists_default_and_selection_actions(qt_app):
|
||||||
settings = make_settings()
|
settings = make_settings()
|
||||||
dialog = ServerManagementDialog(settings)
|
dialog = ServerManagementDialog(settings)
|
||||||
@@ -106,6 +142,18 @@ def test_management_dialog_remove_confirmed_server(qt_app, monkeypatch):
|
|||||||
assert dialog.serverList.currentItem().data(Qt.UserRole) == "Backup"
|
assert dialog.serverList.currentItem().data(Qt.UserRole) == "Backup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_management_dialog_remove_discards_edit_state(qt_app, monkeypatch):
|
||||||
|
settings = make_settings()
|
||||||
|
dialog = ServerManagementDialog(settings)
|
||||||
|
dialog.editedServerNames.add("Primary")
|
||||||
|
monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes)
|
||||||
|
|
||||||
|
dialog.removeSelected()
|
||||||
|
|
||||||
|
assert dialog.removedServerNames == {"Primary"}
|
||||||
|
assert dialog.editedServerNames == set()
|
||||||
|
|
||||||
|
|
||||||
def test_management_dialog_remove_canceled_server(qt_app, monkeypatch):
|
def test_management_dialog_remove_canceled_server(qt_app, monkeypatch):
|
||||||
settings = make_settings()
|
settings = make_settings()
|
||||||
dialog = ServerManagementDialog(settings)
|
dialog = ServerManagementDialog(settings)
|
||||||
|
|||||||
Reference in New Issue
Block a user