Tons of work to make configuration work
This commit is contained in:
@@ -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__ = (
|
||||
|
@@ -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.
|
||||
|
349
sublime/adapters/configure_server_form.py
Normal file
349
sublime/adapters/configure_server_form.py
Normal 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())
|
||||
)
|
@@ -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,
|
||||
|
0
sublime/adapters/icons/config-error-symbolic.svg
Normal file
0
sublime/adapters/icons/config-error-symbolic.svg
Normal file
0
sublime/adapters/icons/config-ok-symbolic.svg
Normal file
0
sublime/adapters/icons/config-ok-symbolic.svg
Normal 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)
|
||||
|
@@ -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 |
@@ -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):
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
|
@@ -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):
|
||||
|
@@ -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()
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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
|
||||
|
@@ -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():
|
||||
|
Reference in New Issue
Block a user