Tons of work to make configuration work

This commit is contained in:
Sumner Evans
2020-06-06 15:05:17 -06:00
parent e850f301ea
commit 3f128604de
16 changed files with 589 additions and 210 deletions

View File

@@ -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__ = (

View File

@@ -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.

View File

@@ -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="<b>Advanced Settings</b>", 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())
)

View File

@@ -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,

View File

@@ -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__"] = (
"<b>Unable to connect to the server.</b>\n"
"Double check the server address."
)
except ServerError as e:
errors["__ping__"] = (
"<b>Error connecting in to the server.</b>\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)

View File

@@ -1,4 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24">
<path d="M6.93 2.13v.04h-.08l-1.39.26c-.15.04-.22.2-.22.38l.22 4.39c0 .22.16.37.34.34l1.69-.57c.11-.03.19-.18.15-.37l-.49-4.17c0-.18-.11-.3-.22-.3zm4.65 4.54c-1.35 0-2.25 0-3.49.68l-2.32.82c-.15.04-.23.19-.27.34a3.6 3.6 0 00-.15 2.1 24 24 0 00-1.46.75c-.45-.71-1.87-.64-1.87.37-.08.5-.42 1.2.22 1.47.3.37-.53.75-.68 1.16-.15.37-.22.75-.7.68-.5.18-.72.63-.34 1.04.37.3 1 0 .9.68.07.49.4.97.6 1.35-.45.37-.38.98-.72 1.43-.26.56.15 1.27.83 1.12a4.73 4.73 0 002.02-.75c3 .82 6.03 1.75 9.13 1.92a6.1 6.1 0 01-.86-4.97l-.02-.21c0-1.02.19-1.62.83-2.25.26-.27.64-.43 1.04-.52a6.1 6.1 0 013.97-1.5 6.01 6.01 0 015.43 3.43 5.5 5.5 0 00-.36-1.94c-.52-1.42-1.87-2.51-3.15-3.26-.75-.38-1.72-.41-2.54-.53l-.27-1.27c.11-.68-.64-.6-1.12-.75l-2.03-.34c-.48-.67-1.87-1.05-2.62-1.05zM5.9 13.05c.38.03.57.67.57 1.83 0 1.1-.23 1.77-.75 2.25-.38.34-.6.3-.83-.18-.45-1.05-.22-2.89.41-3.57.23-.22.42-.37.6-.33zm3.72.22c.56 0 .86.6.93 1.77.04 1.3-.26 2.2-.97 2.73-.34.23-.75.34-.97.23-.3-.12-.64-.98-.68-1.7-.11-1.38.41-2.58 1.27-2.92.13-.06.27-.1.42-.1z" fill="#e4e4e4"/>
<path d="M18.2 13.17A5.23 5.23 0 0013 18.4a5.23 5.23 0 005.24 5.24 5.23 5.23 0 005.23-5.24 5.23 5.23 0 00-5.27-5.23z" fill="#c70e0e" class="success"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"> <path fill="#e4e4e4" d="M6.93 2.17h-.08l-1.39.26c-.15.04-.22.2-.22.38l.22 4.39c0 .22.15.37.34.33l1.69-.56c.11-.04.19-.19.15-.37l-.49-4.17c0-.18-.11-.3-.22-.3zm4.65 4.5c-1.35 0-2.25 0-3.49.68l-2.33.82c-.15.04-.22.19-.26.34a3.6 3.6 0 00-.15 2.1 24 24 0 00-1.46.75c-.45-.71-1.88-.64-1.88.37-.07.5-.4 1.2.23 1.47.3.37-.53.75-.68 1.16-.15.37-.22.75-.7.67-.5.2-.72.64-.35 1.05.38.3 1.02 0 .9.68.08.49.42.97.6 1.35-.45.37-.37.97-.7 1.42-.27.57.14 1.28.82 1.13a4.72 4.72 0 002.02-.75c3.83 1.05 7.69 2.29 11.7 1.87 2.78-.18 5.81-1.23 7.28-3.75.71-1.27.67-2.88.18-4.16-.52-1.42-1.87-2.51-3.15-3.26-.75-.38-1.72-.41-2.55-.53l-.26-1.27c.11-.68-.64-.6-1.12-.75l-2.03-.34c-.49-.67-1.87-1.05-2.62-1.05zM5.9 13.05c.38.03.57.67.57 1.83 0 1.1-.23 1.77-.75 2.25-.38.34-.6.3-.83-.18-.45-1.05-.22-2.9.41-3.57.23-.22.42-.37.6-.33zm3.72.22c.56 0 .86.6.93 1.76.04 1.32-.26 2.22-.97 2.74-.34.23-.75.34-.98.23-.3-.12-.63-.98-.67-1.7-.11-1.38.41-2.58 1.27-2.92a1.13 1.13 0 01.42-.1zm5.13.53c.6 0 1.17.18 1.47.63.3.57.41 1.8.22 2.48a3.41 3.41 0 01-1.16 1.72c-.6.38-1.65.45-2.1.12-.56-.45-.75-1.05-.79-2.1 0-1.02.19-1.62.83-2.25.37-.38.97-.57 1.53-.6z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

@@ -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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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"<b>{app_config.provider.name}</b>")
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("<i>No Music Source Selected</i>")
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,
)

View File

@@ -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

View File

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