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():