diff --git a/sublime/adapters/__init__.py b/sublime/adapters/__init__.py
index 1f0d5cb..f2a69eb 100644
--- a/sublime/adapters/__init__.py
+++ b/sublime/adapters/__init__.py
@@ -3,12 +3,11 @@ from .adapter_base import (
AlbumSearchQuery,
CacheMissError,
CachingAdapter,
- ConfigParamDescriptor,
ConfigurationStore,
- ConfigureServerForm,
SongCacheStatus,
UIInfo,
)
+from .configure_server_form import ConfigParamDescriptor, ConfigureServerForm
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
__all__ = (
diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py
index f0e9683..c7dcaa5 100644
--- a/sublime/adapters/adapter_base.py
+++ b/sublime/adapters/adapter_base.py
@@ -3,20 +3,15 @@ import hashlib
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
-from functools import partial
from pathlib import Path
from typing import (
Any,
- Callable,
- cast,
Dict,
Iterable,
Optional,
Sequence,
Set,
Tuple,
- Type,
- Union,
)
from dataclasses_json import dataclass_json
@@ -168,6 +163,10 @@ class ConfigurationStore:
def __init__(self):
self._store: Dict[str, Any] = {}
+ def __repr__(self) -> str:
+ values = ", ".join(f"{k}={v!r}" for k, v in self._store.items())
+ return f"ConfigurationStore({values})"
+
def get(self, key: str, default: Any = None) -> Any:
"""
Get the configuration value in the store with the given key. If the key doesn't
@@ -200,133 +199,8 @@ class ConfigurationStore:
"""
self._store[key] = value
-
-@dataclass
-class ConfigParamDescriptor:
- """
- Describes a parameter that can be used to configure an adapter. The
- :class:`description`, :class:`required` and :class:`default:` should be self-evident
- as to what they do.
-
- The :class:`type` must be one of the following:
-
- * The literal type ``str``: corresponds to a freeform text entry field in the UI.
- * The literal type ``bool``: corresponds to a checkbox in the UI.
- * The literal type ``int``: corresponds to a numeric input in the UI.
- * The literal string ``"password"``: corresponds to a password entry field in the
- UI.
- * The literal string ``"option"``: corresponds to dropdown in the UI.
- * The literal string ``"directory"``: corresponds to a directory picker in the UI.
- * The literal sting ``"fold"``: corresponds to a expander where following components
- are under an expander component. The title of the expander is the description of
- this class. All of the components until the next ``"fold"``, and ``"endfold"``
- component, or the end of the configuration paramter dictionary are included under
- the expander component.
- * The literal string ``endfold``: end a ``"fold"``. The description must be the same
- as the corresponding start form.
-
- The :class:`hidden_behind` is an optional string representing the name of the
- expander that the component should be displayed underneath. For example, one common
- value for this is "Advanced" which will make the component only visible when the
- user expands the "Advanced" settings.
-
- The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
- `int`. It specifies the min and max values that the UI control can have.
-
- The :class:`numeric_step` parameter only has an effect if the :class:`type` is
- `int`. It specifies the step that will be taken using the "+" and "-" buttons on the
- UI control (if supported).
-
- The :class:`options` parameter only has an effect if the :class:`type` is
- ``"option"``. It specifies the list of options that will be available in the
- dropdown in the UI.
- """
-
- type: Union[Type, str]
- description: str
- required: bool = True
- hidden_behind: Optional[str] = None
- default: Any = None
- numeric_bounds: Optional[Tuple[int, int]] = None
- numeric_step: Optional[int] = None
- options: Optional[Iterable[str]] = None
-
-
-class ConfigureServerForm(Gtk.Box):
- def __init__(
- self,
- config_store: ConfigurationStore,
- config_parameters: Dict[str, ConfigParamDescriptor],
- verify_configuration: Callable[[], Dict[str, Optional[str]]],
- ):
- """
- Inititialize a :class:`ConfigureServerForm` with the given configuration
- parameters.
-
- :param config_store: The :class:`ConfigurationStore` to use to store
- configuration values for this adapter.
- :param config_parameters: An dictionary where the keys are the name of the
- configuration paramter and the values are the :class:`ConfigParamDescriptor`
- object corresponding to that configuration parameter. The order of the keys
- in the dictionary correspond to the order that the configuration parameters
- will be shown in the UI.
- :param verify_configuration: A function that verifies whether or not the
- current state of the ``config_store`` is valid. The output should be a
- dictionary containing verification errors. The keys of the returned
- dictionary should be the same as the keys passed in via the
- ``config_parameters`` parameter. The values should be strings describing
- why the corresponding value in the ``config_store`` is invalid.
- """
- Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
- content_grid = Gtk.Grid(
- column_spacing=10, row_spacing=5, margin_left=10, margin_right=10,
- )
-
- def create_string_input(
- is_password: bool, i: int, key: str, cpd: ConfigParamDescriptor
- ):
- label = Gtk.Label(label=cpd.description + ":", halign=Gtk.Align.END)
- content_grid.attach(label, 0, i, 1, 1)
-
- entry = Gtk.Entry(text=config_store.get(key, ""), hexpand=True)
- if is_password:
- entry.set_visibility(False)
-
- entry.connect(
- "changed",
- lambda e: cast(
- Callable[[str, str], None],
- (config_store.set_secret if is_password else config_store.set),
- )(key, e.get_text()),
- )
- content_grid.attach(entry, 1, i, 1, 1)
-
- def create_bool_input(i: int, key: str, cpd: ConfigParamDescriptor):
- label = Gtk.Label(label=cpd.description + ":")
- label.set_halign(Gtk.Align.END)
- content_grid.attach(label, 0, i, 1, 1)
-
- switch = Gtk.Switch(
- active=config_store.get(key, False), halign=Gtk.Align.START
- )
- switch.connect(
- "notify::active", lambda s: config_store.set(key, s.get_active()),
- )
- content_grid.attach(switch, 1, i, 1, 1)
-
- for i, (key, cpd) in enumerate(config_parameters.items()):
- cast(
- Callable[[int, str, ConfigParamDescriptor], None],
- {
- str: partial(create_string_input, False),
- "password": partial(create_string_input, True),
- bool: create_bool_input,
- "fold": lambda *a: print("fold"), # TODO: this will require making
- # it a state machine
- }[cpd.type],
- )(i, key, cpd)
-
- self.pack_start(content_grid, False, False, 10)
+ def keys(self) -> Iterable[str]:
+ return self._store.keys()
@dataclass
@@ -371,6 +245,10 @@ class Adapter(abc.ABC):
from the user and uses the given ``config_store`` to store the configuration
values.
+ The ``Gtk.Box`` must expose a signal with the name ``"config-valid-changed"``
+ which returns a single boolean value indicating whether or not the configuration
+ is valid.
+
If you don't want to implement all of the GTK logic yourself, and just want a
simple form, then you can use the :class:`ConfigureServerForm` class to generate
a form in a declarative manner.
diff --git a/sublime/adapters/configure_server_form.py b/sublime/adapters/configure_server_form.py
new file mode 100644
index 0000000..0f4aa80
--- /dev/null
+++ b/sublime/adapters/configure_server_form.py
@@ -0,0 +1,349 @@
+"""
+This file contains all of the classes related for a shared server configuration form.
+"""
+
+from dataclasses import dataclass
+from functools import partial
+from pathlib import Path
+from typing import Any, Callable, cast, Dict, Iterable, Optional, Tuple, Type, Union
+
+from gi.repository import Gdk, GLib, GObject, Gtk, Pango
+
+from . import ConfigurationStore
+
+
+@dataclass
+class ConfigParamDescriptor:
+ """
+ Describes a parameter that can be used to configure an adapter. The
+ :class:`description`, :class:`required` and :class:`default:` should be self-evident
+ as to what they do.
+
+ The :class:`helptext` parameter is optional detailed text that will be shown in a
+ help bubble corresponding to the field.
+
+ The :class:`type` must be one of the following:
+
+ * The literal type ``str``: corresponds to a freeform text entry field in the UI.
+ * The literal type ``bool``: corresponds to a toggle in the UI.
+ * The literal type ``int``: corresponds to a numeric input in the UI.
+ * The literal string ``"password"``: corresponds to a password entry field in the
+ UI.
+ * The literal string ``"option"``: corresponds to dropdown in the UI.
+ * The literal type ``Path``: corresponds to a file picker in the UI.
+
+ The :class:`advanced` parameter specifies whether the setting should be behind an
+ "Advanced" expander.
+
+ The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is
+ `int`. It specifies the min and max values that the UI control can have.
+
+ The :class:`numeric_step` parameter only has an effect if the :class:`type` is
+ `int`. It specifies the step that will be taken using the "+" and "-" buttons on the
+ UI control (if supported).
+
+ The :class:`options` parameter only has an effect if the :class:`type` is
+ ``"option"``. It specifies the list of options that will be available in the
+ dropdown in the UI.
+
+ The :class:`pathtype` parameter only has an effect if the :class:`type` is
+ ``Path``. It can be either ``"file"`` or ``"directory"`` corresponding to a file
+ picker and a directory picker, respectively.
+ """
+
+ type: Union[Type, str]
+ description: str
+ required: bool = True
+ helptext: Optional[str] = None
+ advanced: Optional[bool] = None
+ default: Any = None
+ numeric_bounds: Optional[Tuple[int, int]] = None
+ numeric_step: Optional[int] = None
+ options: Optional[Iterable[str]] = None
+ pathtype: Optional[str] = None
+
+
+class ConfigureServerForm(Gtk.Box):
+ __gsignals__ = {
+ "config-valid-changed": (
+ GObject.SignalFlags.RUN_FIRST,
+ GObject.TYPE_NONE,
+ (bool,),
+ ),
+ }
+
+ def __init__(
+ self,
+ config_store: ConfigurationStore,
+ config_parameters: Dict[str, ConfigParamDescriptor],
+ verify_configuration: Callable[[], Dict[str, Optional[str]]],
+ is_networked: bool = True,
+ ):
+ """
+ Inititialize a :class:`ConfigureServerForm` with the given configuration
+ parameters.
+
+ :param config_store: The :class:`ConfigurationStore` to use to store
+ configuration values for this adapter.
+ :param config_parameters: An dictionary where the keys are the name of the
+ configuration paramter and the values are the :class:`ConfigParamDescriptor`
+ object corresponding to that configuration parameter. The order of the keys
+ in the dictionary correspond to the order that the configuration parameters
+ will be shown in the UI.
+ :param verify_configuration: A function that verifies whether or not the
+ current state of the ``config_store`` is valid. The output should be a
+ dictionary containing verification errors. The keys of the returned
+ dictionary should be the same as the keys passed in via the
+ ``config_parameters`` parameter. The values should be strings describing
+ why the corresponding value in the ``config_store`` is invalid.
+
+ If the adapter ``is_networked``, and the special ``"__ping__"`` key is
+ returned, then the error will be shown below all of the other settings in
+ the ping status box.
+ :param is_networked: whether or not the adapter is networked.
+ """
+ Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
+ self.config_store = config_store
+ self.required_config_parameter_keys = set()
+ self.verify_configuration = verify_configuration
+ self.entries = {}
+ self.is_networked = is_networked
+
+ content_grid = Gtk.Grid(
+ column_spacing=10, row_spacing=5, margin_left=10, margin_right=10,
+ )
+ advanced_grid = Gtk.Grid(column_spacing=10, row_spacing=10)
+
+ def create_string_input(is_password: bool, key: str) -> Gtk.Entry:
+ entry = Gtk.Entry(
+ text=cast(
+ Callable[[str], None],
+ (config_store.get_secret if is_password else config_store.get),
+ )(key),
+ hexpand=True,
+ )
+ if is_password:
+ entry.set_visibility(False)
+
+ entry.connect(
+ "changed",
+ lambda e: self._on_config_change(key, e.get_text(), secret=is_password),
+ )
+ return entry
+
+ def create_bool_input(key: str) -> Gtk.Switch:
+ switch = Gtk.Switch(active=config_store.get(key), halign=Gtk.Align.START)
+ switch.connect(
+ "notify::active",
+ lambda s, _: self._on_config_change(key, s.get_active()),
+ )
+ return switch
+
+ def create_int_input(key: str) -> Gtk.SpinButton:
+ raise NotImplementedError()
+
+ def create_option_input(key: str) -> Gtk.ComboBox:
+ raise NotImplementedError()
+
+ def create_path_input(key: str) -> Gtk.FileChooser:
+ raise NotImplementedError()
+
+ content_grid_i = 0
+ advanced_grid_i = 0
+ for key, cpd in config_parameters.items():
+ if cpd.required:
+ self.required_config_parameter_keys.add(key)
+ if cpd.default is not None:
+ config_store.set(key, config_store.get(key, cpd.default))
+
+ label = Gtk.Label(cpd.description + ":", halign=Gtk.Align.END)
+
+ input_el_box = Gtk.Box()
+ self.entries[key] = cast(
+ Callable[[str], Gtk.Widget],
+ {
+ str: partial(create_string_input, False),
+ "password": partial(create_string_input, True),
+ bool: create_bool_input,
+ int: create_int_input,
+ "option": create_option_input,
+ Path: create_path_input,
+ }[cpd.type],
+ )(key)
+ input_el_box.add(self.entries[key])
+
+ if cpd.helptext:
+ help_icon = Gtk.Image.new_from_icon_name(
+ "help-about", Gtk.IconSize.BUTTON,
+ )
+ help_icon.get_style_context().add_class("configure-form-help-icon")
+ help_icon.set_tooltip_markup(cpd.helptext)
+ input_el_box.add(help_icon)
+
+ if not cpd.advanced:
+ content_grid.attach(label, 0, content_grid_i, 1, 1)
+ content_grid.attach(input_el_box, 1, content_grid_i, 1, 1)
+ content_grid_i += 1
+ else:
+ advanced_grid.attach(label, 0, advanced_grid_i, 1, 1)
+ advanced_grid.attach(input_el_box, 1, advanced_grid_i, 1, 1)
+ advanced_grid_i += 1
+
+ # Add a button and revealer for the advanced section of the configuration.
+ if advanced_grid_i > 0:
+ advanced_component = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+
+ advanced_expander = Gtk.Revealer()
+ advanced_expander_icon = Gtk.Image.new_from_icon_name(
+ "go-down-symbolic", Gtk.IconSize.BUTTON
+ )
+ revealed = False
+
+ def toggle_expander(*args):
+ nonlocal revealed
+ revealed = not revealed
+ advanced_expander.set_reveal_child(revealed)
+ icon_dir = "up" if revealed else "down"
+ advanced_expander_icon.set_from_icon_name(
+ f"go-{icon_dir}-symbolic", Gtk.IconSize.BUTTON
+ )
+
+ advanced_expander_button = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
+ advanced_expander_button_box = Gtk.Box(
+ orientation=Gtk.Orientation.HORIZONTAL, spacing=10
+ )
+
+ advanced_label = Gtk.Label(
+ label="Advanced Settings", use_markup=True
+ )
+ advanced_expander_button_box.add(advanced_label)
+ advanced_expander_button_box.add(advanced_expander_icon)
+
+ advanced_expander_button.add(advanced_expander_button_box)
+ advanced_expander_button.connect("clicked", toggle_expander)
+ advanced_component.add(advanced_expander_button)
+
+ advanced_expander.add(advanced_grid)
+ advanced_component.add(advanced_expander)
+
+ content_grid.attach(advanced_component, 0, content_grid_i, 2, 1)
+ content_grid_i += 1
+
+ content_grid.attach(
+ Gtk.Separator(name="config-verification-separator"), 0, content_grid_i, 2, 1
+ )
+ content_grid_i += 1
+
+ self.config_verification_box = Gtk.Box(spacing=10)
+ content_grid.attach(self.config_verification_box, 0, content_grid_i, 2, 1)
+
+ self.pack_start(content_grid, False, False, 10)
+ self._verification_status_ratchet = 0
+ self._verify_config(self._verification_status_ratchet)
+
+ had_all_required_keys = False
+ verifying_in_progress = False
+
+ def _set_verification_status(
+ self, verifying: bool, is_valid: bool = False, error_text: str = None
+ ):
+ from sublime.ui import util
+
+ if verifying:
+ if not self.verifying_in_progress:
+ for c in self.config_verification_box.get_children():
+ self.config_verification_box.remove(c)
+ self.config_verification_box.add(Gtk.Spinner(active=True))
+ self.config_verification_box.add(
+ Gtk.Label(label="Verifying configuration...")
+ )
+ self.verifying_in_progress = True
+ else:
+ self.verifying_in_progress = False
+ for c in self.config_verification_box.get_children():
+ self.config_verification_box.remove(c)
+
+ def set_icon_and_label(icon_name: str, label_text: str):
+ self.config_verification_box.add(
+ Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON)
+ )
+ label = Gtk.Label(
+ label=label_text,
+ use_markup=True,
+ ellipsize=Pango.EllipsizeMode.END,
+ )
+ label.set_tooltip_markup(label_text)
+ self.config_verification_box.add(label)
+
+ if is_valid:
+ set_icon_and_label("config-ok-symbolic", "Configuration is valid")
+ elif escaped := util.esc(error_text):
+ set_icon_and_label("config-error-symbolic", escaped)
+
+ self.config_verification_box.show_all()
+
+ def _on_config_change(self, key: str, value: Any, secret: bool = False):
+ set_fn = cast(
+ Callable[[str, Any], None],
+ (self.config_store.set_secret if secret else self.config_store.set),
+ )
+ set_fn(key, value)
+ self._verification_status_ratchet += 1
+ self._verify_config(self._verification_status_ratchet)
+
+ def _verify_config(self, ratchet: int):
+ self.emit("config-valid-changed", False)
+
+ from sublime.adapters import Result
+
+ if self.required_config_parameter_keys.issubset(set(self.config_store.keys())):
+ if self._verification_status_ratchet != ratchet:
+ return
+
+ self._set_verification_status(True)
+
+ has_empty = False
+ if self.had_all_required_keys:
+ for key in self.required_config_parameter_keys:
+ if self.config_store.get(key) == "":
+ self.entries[key].get_style_context().add_class("invalid")
+ self.entries[key].set_tooltip_markup("This field is required")
+ has_empty = True
+ else:
+ self.entries[key].get_style_context().remove_class("invalid")
+ self.entries[key].set_tooltip_markup(None)
+
+ self.had_all_required_keys = True
+ if has_empty:
+ self._set_verification_status(False)
+ return
+
+ def on_verify_result(verification_errors: Dict[str, Optional[str]]):
+ if self._verification_status_ratchet != ratchet:
+ return
+
+ if len(verification_errors) == 0:
+ self.emit("config-valid-changed", True)
+ for entry in self.entries.values():
+ entry.get_style_context().remove_class("invalid")
+ self._set_verification_status(False, is_valid=True)
+ return
+
+ for key, entry in self.entries.items():
+ if error_text := verification_errors.get(key):
+ entry.get_style_context().add_class("invalid")
+ entry.set_tooltip_markup(error_text)
+ else:
+ entry.get_style_context().remove_class("invalid")
+ entry.set_tooltip_markup(None)
+
+ self._set_verification_status(
+ False, error_text=verification_errors.get("__ping__")
+ )
+
+ errors_result: Result[Dict[str, Optional[str]]] = Result(
+ self.verify_configuration
+ )
+ errors_result.add_done_callback(
+ lambda f: GLib.idle_add(on_verify_result, f.result())
+ )
diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py
index d2dab0b..1507a47 100644
--- a/sublime/adapters/filesystem/adapter.py
+++ b/sublime/adapters/filesystem/adapter.py
@@ -50,7 +50,7 @@ class FilesystemAdapter(CachingAdapter):
config_store,
{
"directory": ConfigParamDescriptor(
- type="directory", description="Music Directory"
+ type=Path, description="Music Directory", pathtype="directory"
)
},
verify_config_store,
diff --git a/sublime/adapters/icons/config-error-symbolic.svg b/sublime/adapters/icons/config-error-symbolic.svg
new file mode 100644
index 0000000..e69de29
diff --git a/sublime/adapters/icons/config-ok-symbolic.svg b/sublime/adapters/icons/config-ok-symbolic.svg
new file mode 100644
index 0000000..e69de29
diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py
index 1975c4e..be7b44f 100644
--- a/sublime/adapters/subsonic/adapter.py
+++ b/sublime/adapters/subsonic/adapter.py
@@ -5,6 +5,7 @@ import multiprocessing
import os
import pickle
import random
+import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from time import sleep
@@ -64,6 +65,12 @@ if always_error := os.environ.get("NETWORK_ALWAYS_ERROR"):
NETWORK_ALWAYS_ERROR = True
+class ServerError(Exception):
+ def __init__(self, status_code: int, message: str):
+ self.status_code = status_code
+ super().__init__(message)
+
+
class SubsonicAdapter(Adapter):
"""
Defines an adapter which retrieves its data from a Subsonic server
@@ -83,15 +90,23 @@ class SubsonicAdapter(Adapter):
@staticmethod
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
configs = {
- "server_address": ConfigParamDescriptor(str, "Server address"),
+ "server_address": ConfigParamDescriptor(str, "Server Address"),
"username": ConfigParamDescriptor(str, "Username"),
"password": ConfigParamDescriptor("password", "Password"),
- "-": ConfigParamDescriptor("fold", "Advanced"),
- "disable_cert_verify": ConfigParamDescriptor(
- bool, "Verify certificate", True
+ "verify_cert": ConfigParamDescriptor(
+ bool,
+ "Verify Certificate",
+ default=True,
+ advanced=True,
+ helptext="Whether or not to verify the SSL certificate of the server.",
),
"sync_enabled": ConfigParamDescriptor(
- bool, "Synchronize play queue state", True
+ bool,
+ "Sync Slay Queue",
+ default=True,
+ advanced=True,
+ helptext="If toggled, Sublime Music will periodically save the play "
+ "queue state so that you can resume on other devices.",
),
}
@@ -99,10 +114,22 @@ class SubsonicAdapter(Adapter):
configs.update(
{
"local_network_ssid": ConfigParamDescriptor(
- str, "Local Network SSID"
+ str,
+ "Local Network SSID",
+ advanced=True,
+ required=False,
+ helptext="If Sublime Music is connected to the given SSID, the "
+ "Local Network Address will be used instead of the Server "
+ "address when making network requests.",
),
"local_network_address": ConfigParamDescriptor(
- str, "Local Network Address"
+ str,
+ "Local Network Address",
+ advanced=True,
+ required=False,
+ helptext="If Sublime Music is connected to the given Local "
+ "Network SSID, this URL will be used instead of the Server "
+ "address when making network requests.",
),
}
)
@@ -110,10 +137,27 @@ class SubsonicAdapter(Adapter):
def verify_configuration() -> Dict[str, Optional[str]]:
errors: Dict[str, Optional[str]] = {}
- # TODO (#197): verify the URL and ping it.
- # Maybe have a special key like __ping_future__ or something along those
- # lines to add a function that allows the UI to check whether or not
- # connecting to the server will work?
+ with tempfile.TemporaryDirectory() as tmp_dir_name:
+ try:
+ tmp_adapter = SubsonicAdapter(config_store, Path(tmp_dir_name))
+ tmp_adapter._get_json(
+ tmp_adapter._make_url("ping"),
+ timeout=2,
+ is_exponential_backoff_ping=True,
+ )
+ except requests.ConnectionError:
+ errors["__ping__"] = (
+ "Unable to connect to the server.\n"
+ "Double check the server address."
+ )
+ except ServerError as e:
+ errors["__ping__"] = (
+ "Error connecting in to the server.\n"
+ f"Error {e.status_code}: {str(e)}"
+ )
+ except Exception as e:
+ errors["__ping__"] = str(e)
+
return errors
return ConfigureServerForm(config_store, configs, verify_configuration)
@@ -122,14 +166,18 @@ class SubsonicAdapter(Adapter):
def migrate_configuration(config_store: ConfigurationStore):
pass
- def __init__(self, config: dict, data_directory: Path):
+ def __init__(self, config: ConfigurationStore, data_directory: Path):
self.data_directory = data_directory
self.ignored_articles_cache_file = self.data_directory.joinpath(
"ignored_articles.pickle"
)
- self.hostname = config["server_address"]
- if ssid := config.get("local_network_ssid") and networkmanager_imported:
+ self.hostname = config.get("server_address")
+ if (
+ (ssid := config.get("local_network_ssid"))
+ and (lan_address := config.get("local_network_address"))
+ and networkmanager_imported
+ ):
networkmanager_client = NM.Client.new()
# Only look at the active WiFi connections.
@@ -145,12 +193,16 @@ class SubsonicAdapter(Adapter):
# If connected to the Local Network SSID, then change the hostname to
# the Local Network Address.
if ssid == ac.get_id():
- self.hostname = config["local_network_address"]
+ self.hostname = lan_address
break
- self.username = config["username"]
- self.password = config["password"]
- self.disable_cert_verify = config.get("disable_cert_verify")
+ parsed_hostname = urlparse(self.hostname)
+ if not parsed_hostname.scheme:
+ self.hostname = "https://" + self.hostname
+
+ self.username = config.get("username")
+ self.password = config.get_secret("password")
+ self.verify_cert = config.get("verify_cert")
self.is_shutting_down = False
@@ -298,7 +350,7 @@ class SubsonicAdapter(Adapter):
raise TimeoutError("DUMMY TIMEOUT ERROR")
if NETWORK_ALWAYS_ERROR:
- raise Exception("NETWORK_ALWAYS_ERROR enabled")
+ raise ServerError(69, "NETWORK_ALWAYS_ERROR enabled")
# Deal with datetime parameters (convert to milliseconds since 1970)
for k, v in params.items():
@@ -310,22 +362,21 @@ class SubsonicAdapter(Adapter):
result = self._get_mock_data()
else:
result = requests.get(
- url,
- params=params,
- verify=not self.disable_cert_verify,
- timeout=timeout,
+ url, params=params, verify=self.verify_cert, timeout=timeout,
)
# TODO (#122): make better
if result.status_code != 200:
- raise Exception(f"[FAIL] get: {url} status={result.status_code}")
+ raise ServerError(
+ result.status_code, f"{url} returned status={result.status_code}."
+ )
# Any time that a server request succeeds, then we win.
self._server_available.value = True
self._last_ping_timestamp.value = datetime.now().timestamp()
except Exception:
- logging.exception(f"get: {url} failed")
+ logging.exception(f"[FAIL] get: {url} failed")
self._server_available.value = False
self._last_ping_timestamp.value = datetime.now().timestamp()
if not is_exponential_backoff_ping:
@@ -359,14 +410,13 @@ class SubsonicAdapter(Adapter):
# TODO (#122): make better
if not subsonic_response:
- raise Exception(f"[FAIL] get: invalid JSON from {url}")
+ raise ServerError(500, f"{url} returned invalid JSON.")
if subsonic_response["status"] == "failed":
- code, message = (
+ raise ServerError(
subsonic_response["error"].get("code"),
subsonic_response["error"].get("message"),
)
- raise Exception(f"Subsonic API Error #{code}: {message}")
logging.debug(f"Response from {url}: {subsonic_response}")
return Response.from_dict(subsonic_response)
diff --git a/sublime/adapters/subsonic/icons/subsonic-symbolic.svg b/sublime/adapters/subsonic/icons/subsonic-symbolic.svg
index 016be07..451276f 100644
--- a/sublime/adapters/subsonic/icons/subsonic-symbolic.svg
+++ b/sublime/adapters/subsonic/icons/subsonic-symbolic.svg
@@ -1,4 +1 @@
-
+
diff --git a/sublime/app.py b/sublime/app.py
index 7185312..516a594 100644
--- a/sublime/app.py
+++ b/sublime/app.py
@@ -123,8 +123,12 @@ class SublimeMusicApp(Gtk.Application):
if icon_dir := adapter.get_ui_info().icon_dir:
default_icon_theme.append_search_path(str(icon_dir))
- icon_dir = Path(__file__).parent.joinpath("ui", "icons")
- default_icon_theme.append_search_path(str(icon_dir))
+ icon_dirs = [
+ Path(__file__).parent.joinpath("ui", "icons"),
+ Path(__file__).parent.joinpath("adapters", "icons"),
+ ]
+ for icon_dir in icon_dirs:
+ default_icon_theme.append_search_path(str(icon_dir))
# Windows are associated with the application when the last one is
# closed the application shuts down.
@@ -921,9 +925,11 @@ class SublimeMusicApp(Gtk.Application):
"""Show the Connect to Server dialog."""
dialog = ConfigureProviderDialog(self.window, provider_config)
result = dialog.run()
- print(result)
- print(dialog)
- print(dialog.provider_config)
+ if result == Gtk.ResponseType.APPLY:
+ assert dialog.provider_config is not None
+ provider_id = dialog.provider_config.id
+ self.app_config.providers[provider_id] = dialog.provider_config
+ self.app_config.save()
dialog.destroy()
def update_window(self, force: bool = False):
diff --git a/sublime/config.py b/sublime/config.py
index 71cb07f..6ed61b8 100644
--- a/sublime/config.py
+++ b/sublime/config.py
@@ -1,10 +1,11 @@
import logging
import os
import pickle
+from abc import ABCMeta
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
-from typing import Dict, Optional, Type
+from typing import Any, Dict, Optional, Type
import dataclasses_json
from dataclasses_json import dataclass_json, DataClassJsonMixin
@@ -48,7 +49,6 @@ class ReplayGainType(Enum):
}[replay_gain_type.lower()]
-@dataclass_json
@dataclass
class ProviderConfiguration:
id: str
@@ -109,7 +109,7 @@ class AppConfiguration(DataClassJsonMixin):
if not self.cache_location:
path = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
path = path.expanduser().joinpath("sublime-music").resolve()
- self.cache_location = str(path)
+ self.cache_location = path
self._state = None
self._current_provider_id = None
diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css
index 4c91109..9fc9113 100644
--- a/sublime/ui/app_styles.css
+++ b/sublime/ui/app_styles.css
@@ -69,6 +69,22 @@
margin: 0 40px;
}
+#music-source-config-name-entry-grid {
+ margin: 10px 0;
+}
+
+#config-verification-separator {
+ margin: 5px -10px;
+}
+
+.configure-form-help-icon {
+ margin-left: 10px;
+}
+
+entry.invalid {
+ border-color: red;
+}
+
/* ********** Playlist ********** */
#playlist-list-listbox row {
margin: 0;
diff --git a/sublime/ui/common/icon_button.py b/sublime/ui/common/icon_button.py
index 7233a70..a60b692 100644
--- a/sublime/ui/common/icon_button.py
+++ b/sublime/ui/common/icon_button.py
@@ -16,22 +16,42 @@ class IconButton(Gtk.Button):
Gtk.Button.__init__(self, **kwargs)
self.icon_size = icon_size
- box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
+ self.box = Gtk.Box(
+ orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box"
+ )
- self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
- box.add(self.image)
+ self.image = None
+ self.has_icon = False
+ if icon_name:
+ self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
+ self.has_icon = True
+ self.box.pack_start(self.image, False, False, 0)
if label is not None:
- box.add(Gtk.Label(label=label))
+ self.box.add(Gtk.Label(label=label))
if not relief:
self.props.relief = Gtk.ReliefStyle.NONE
- self.add(box)
+ self.add(self.box)
self.set_tooltip_text(tooltip_text)
def set_icon(self, icon_name: Optional[str]):
- self.image.set_from_icon_name(icon_name, self.icon_size)
+ if icon_name:
+ if self.image is None:
+ self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
+ else:
+ self.image.set_from_icon_name(icon_name, self.icon_size)
+
+ if not self.has_icon:
+ self.box.pack_start(self.image, False, False, 0)
+ self.show_all()
+
+ self.has_icon = True
+ else:
+ if self.has_icon:
+ self.box.remove(self.image)
+ self.has_icon = False
class IconToggleButton(Gtk.ToggleButton):
diff --git a/sublime/ui/configure_provider.py b/sublime/ui/configure_provider.py
index 90a3f06..f5ebe47 100644
--- a/sublime/ui/configure_provider.py
+++ b/sublime/ui/configure_provider.py
@@ -1,10 +1,13 @@
+import uuid
from enum import Enum
from typing import Any, Optional, Type
from gi.repository import Gio, GLib, GObject, Gtk, Pango
-from sublime.adapters import Adapter, AdapterManager, UIInfo
+from sublime.adapters import AdapterManager, UIInfo
+from sublime.adapters.filesystem import FilesystemAdapter
from sublime.config import ConfigurationStore, ProviderConfiguration
+from sublime.ui.common import IconButton
class AdapterTypeModel(GObject.GObject):
@@ -44,15 +47,11 @@ class ConfigureProviderDialog(Gtk.Dialog):
else "Edit {provider_config.name}"
)
Gtk.Dialog.__init__(
- self,
- title=title,
- transient_for=parent,
- flags=Gtk.DialogFlags.MODAL,
- add_buttons=(),
+ self, title=title, transient_for=parent, flags=Gtk.DialogFlags.MODAL
)
# TODO esc should prompt or go back depending on the page
self.provider_config = provider_config
- self.set_default_size(400, 500)
+ self.set_default_size(400, 350)
# HEADER
header = Gtk.HeaderBar()
@@ -77,8 +76,9 @@ class ConfigureProviderDialog(Gtk.Dialog):
adapter_type_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.adapter_type_store = Gio.ListStore()
self.adapter_options_list = Gtk.ListBox(
- name="ground-truth-adapter-options-list"
+ name="ground-truth-adapter-options-list", activate_on_single_click=False
)
+ self.adapter_options_list.connect("row-activated", self._on_next_add_clicked)
def create_row(model: AdapterTypeModel) -> Gtk.ListBoxRow:
ui_info: UIInfo = model.adapter_type.get_ui_info()
@@ -109,7 +109,7 @@ class ConfigureProviderDialog(Gtk.Dialog):
available_ground_truth_adapters = filter(
lambda a: a.can_be_ground_truth, AdapterManager.available_adapters
)
- # TODO
+ # TODO: DEBUG REMOVE NEXT LINE
available_ground_truth_adapters = AdapterManager.available_adapters
for adapter_type in sorted(
available_ground_truth_adapters, key=lambda a: a.get_ui_info().name
@@ -130,16 +130,14 @@ class ConfigureProviderDialog(Gtk.Dialog):
if self.stage == DialogStage.SELECT_ADAPTER:
self.close()
else:
- self.stage = DialogStage.SELECT_ADAPTER
self.stack.set_visible_child_name("select")
+ self.stage = DialogStage.SELECT_ADAPTER
self.cancel_back_button.set_label("Cancel")
self.next_add_button.set_label("Next")
+ self.next_add_button.set_sensitive(True)
- def _on_next_add_clicked(self, _):
+ def _on_next_add_clicked(self, *args):
if self.stage == DialogStage.SELECT_ADAPTER:
- self.stage = DialogStage.CONFIGURE_ADAPTER
- self.cancel_back_button.set_label("Back")
- self.next_add_button.set_label("Add")
# TODO make the next button the primary action
index = self.adapter_options_list.get_selected_row().get_index()
@@ -147,17 +145,79 @@ class ConfigureProviderDialog(Gtk.Dialog):
for c in self.configure_box.get_children():
self.configure_box.remove(c)
- adapter_type = self.adapter_type_store[index].adapter_type
- config_store = (
+ name_entry_grid = Gtk.Grid(
+ column_spacing=10,
+ row_spacing=5,
+ margin_left=10,
+ margin_right=10,
+ name="music-source-config-name-entry-grid",
+ )
+ name_label = Gtk.Label(label="Music Source Name:")
+ name_entry_grid.attach(name_label, 0, 0, 1, 1)
+ self.name_field = Gtk.Entry(
+ text=self.provider_config.name if self.provider_config else "",
+ hexpand=True,
+ )
+ self.name_field.connect("changed", self._on_name_change)
+ name_entry_grid.attach(self.name_field, 1, 0, 1, 1)
+ self.configure_box.add(name_entry_grid)
+
+ self.configure_box.add(Gtk.Separator())
+
+ self.adapter_type = self.adapter_type_store[index].adapter_type
+ self.config_store = (
self.provider_config.ground_truth_adapter_config
if self.provider_config
else ConfigurationStore()
)
- self.configure_box.pack_start(
- adapter_type.get_configuration_form(config_store), True, True, 0,
- )
+ form = self.adapter_type.get_configuration_form(self.config_store)
+ form.connect("config-valid-changed", self._on_config_form_valid_changed)
+ self.configure_box.pack_start(form, True, True, 0)
self.configure_box.show_all()
+ self._current_index = index
self.stack.set_visible_child_name("configure")
+ self.stage = DialogStage.CONFIGURE_ADAPTER
+ self.cancel_back_button.set_label("Back")
+ self.next_add_button.set_label("Add")
+ self.next_add_button.set_sensitive(False)
else:
- print("ADD")
+ if self.provider_config is None:
+ self.provider_config = ProviderConfiguration(
+ str(uuid.uuid4()),
+ self.name_field.get_text(),
+ self.adapter_type,
+ self.config_store,
+ )
+ if self.adapter_type.can_be_cached:
+ # TODO if we ever have more caching adapters, need to change this.
+ self.provider_config.caching_adapter_type = FilesystemAdapter
+ self.provider_config.caching_adapter_config = ConfigurationStore()
+ else:
+ self.provider_config.name = self.name_field.get_text()
+ self.provider_config.ground_truth_adapter_config = self.config_store
+
+ self.response(Gtk.ResponseType.APPLY)
+
+ _name_is_valid = False
+ _adapter_config_is_valid = False
+
+ def _update_add_button_sensitive(self):
+ self.next_add_button.set_sensitive(
+ self._name_is_valid and self._adapter_config_is_valid
+ )
+
+ def _on_name_change(self, entry: Gtk.Entry):
+ if entry.get_text():
+ self._name_is_valid = True
+ entry.get_style_context().remove_class("invalid")
+ entry.set_tooltip_markup(None)
+ else:
+ self._name_is_valid = False
+ entry.get_style_context().add_class("invalid")
+ entry.set_tooltip_markup("This field is required")
+ self._update_add_button_sensitive()
+
+ def _on_config_form_valid_changed(self, _, valid: bool):
+ self._adapter_config_is_valid = valid
+ self._update_add_button_sensitive()
diff --git a/sublime/ui/main.py b/sublime/ui/main.py
index 5a4f6e3..257b2d0 100644
--- a/sublime/ui/main.py
+++ b/sublime/ui/main.py
@@ -139,8 +139,11 @@ class MainWindow(Gtk.ApplicationWindow):
# Update the Connected to label on the popup menu.
if app_config.provider:
self.connected_to_label.set_markup(f"{app_config.provider.name}")
+ ui_info = app_config.provider.ground_truth_adapter_type.get_ui_info()
+ icon_basename = ui_info.icon_basename
else:
self.connected_to_label.set_markup("No Music Source Selected")
+ icon_basename = "list-add"
if AdapterManager.ground_truth_adapter_is_networked:
status_label = ""
@@ -152,7 +155,7 @@ class MainWindow(Gtk.ApplicationWindow):
status_label = "Error Connecting to Server"
self.server_connection_menu_button.set_icon(
- f"server-subsonic-{status_label.split()[0].lower()}-symbolic"
+ f"{icon_basename}-{status_label.split()[0].lower()}-symbolic"
)
self.connection_status_icon.set_from_icon_name(
f"server-{status_label.split()[0].lower()}-symbolic",
@@ -161,6 +164,7 @@ class MainWindow(Gtk.ApplicationWindow):
self.connection_status_label.set_text(status_label)
self.connected_status_box.show_all()
else:
+ self.server_connection_menu_button.set_icon(f"{icon_basename}-symbolic")
self.connected_status_box.hide()
self._updating_settings = True
@@ -402,7 +406,7 @@ class MainWindow(Gtk.ApplicationWindow):
# Server icon and change server dropdown
self.server_connection_popover = self._create_server_connection_popover()
self.server_connection_menu_button = IconMenuButton(
- "server-subsonic-offline-symbolic",
+ "list-add-symbolic",
tooltip_text="Server connection settings",
popover=self.server_connection_popover,
)
diff --git a/tests/adapter_tests/adapter_manager_tests.py b/tests/adapter_tests/adapter_manager_tests.py
index 1f4aa4d..55f8c18 100644
--- a/tests/adapter_tests/adapter_manager_tests.py
+++ b/tests/adapter_tests/adapter_manager_tests.py
@@ -5,19 +5,19 @@ import pytest
from sublime.adapters import AdapterManager, Result, SearchResult
from sublime.adapters.subsonic import api_objects as SubsonicAPI
-from sublime.config import AppConfiguration, ServerConfiguration
+from sublime.config import AppConfiguration, ProviderConfiguration
@pytest.fixture
def adapter_manager(tmp_path: Path):
config = AppConfiguration(
- servers=[
- ServerConfiguration(
+ providers={
+ "1": ProviderConfiguration(
name="foo", server_address="bar", username="baz", password="ohea",
)
- ],
- current_server_index=0,
- cache_location=tmp_path.as_posix(),
+ },
+ current_provider_id="1",
+ cache_location=tmp_path,
)
AdapterManager.reset(config, lambda *a: None)
yield
diff --git a/tests/config_test.py b/tests/config_test.py
index a354035..9659db1 100644
--- a/tests/config_test.py
+++ b/tests/config_test.py
@@ -4,7 +4,7 @@ from pathlib import Path
import yaml
-from sublime.config import AppConfiguration, ReplayGainType, ServerConfiguration
+from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType
def test_config_default_cache_location():