Merge branch '197-adapter-defined-config'

This commit is contained in:
Sumner Evans
2020-06-07 00:42:30 -06:00
30 changed files with 1373 additions and 648 deletions

View File

@@ -49,6 +49,8 @@ class should be enough to implement the entire adapter.
After you've created the class, you will want to implement the following
functions and properties first:
* ``get_ui_info``: Returns a :class:`sublime.adapters.UIInfo` with the info for
the adapter.
* ``__init__``: Used to initialize your adapter. See the
:class:`sublime.adapters.Adapter.__init__` documentation for the function
signature of the ``__init__`` function.
@@ -68,12 +70,18 @@ functions and properties first:
function. Instead, use a periodic ping that updates a state variable that
this function returns.
.. TODO: these are totally wrong
* ``get_configuration_form``: This function should return a :class:`Gtk.Box`
that gets any inputs required from the user and uses the given
``config_store`` to store the configuration values.
* ``get_config_parameters``: Specifies the settings which can be configured on
for the adapter. See :ref:`adapter-api:Handling Configuration` for details.
* ``verify_configuration``: Verifies whether or not a given set of configuration
values are valid. See :ref:`adapter-api:Handling Configuration` for details.
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:`sublime.adapters.ConfigureServerForm` class to generate a form in a
declarative manner.
.. note::

View File

@@ -50,6 +50,7 @@ autodoc_default_options = {
autosectionlabel_prefix_document = True
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"gtk": ("https://lazka.github.io/pgi-docs/Gtk-3.0/", None),
}
# Add any paths that contain templates here, relative to this directory.

View File

@@ -1,11 +1,27 @@
bottle==0.12.18
casttube==0.2.1
certifi==2020.4.5.1
chardet==3.0.4
dataclasses-json==0.4.4
deepdiff==4.3.2
fuzzywuzzy==0.18.0
idna==2.9
ifaddr==0.1.6
marshmallow-enum==1.5.1
marshmallow==3.6.0
mypy-extensions==0.4.3
ordered-set==4.0.1
peewee==3.13.3
protobuf==3.12.2
pychromecast==5.3.0
pycparser==2.20
python-dateutil==2.8.1
python-levenshtein==0.12.0
python-mpv==0.4.6
pyyaml==5.3.1
requests==2.23.0
stringcase==1.2.0
typing-extensions==3.7.4.2
typing-inspect==0.6.0
urllib3==1.25.9
zeroconf==0.27.0

View File

@@ -14,12 +14,16 @@ with open(here.joinpath("sublime", "__init__.py")) as f:
version = eval(line.split()[-1])
break
icons_dir = here.joinpath("sublime", "ui", "icons")
icon_filenames = []
for icon in icons_dir.iterdir():
if not str(icon).endswith(".svg"):
continue
icon_filenames.append(str(icon))
package_data_dirs = [
here.joinpath("sublime", "ui", "icons"),
here.joinpath("sublime", "dbus", "mpris_specs"),
]
package_data_files = []
for data_dir in package_data_dirs:
for file in data_dir.iterdir():
if not str(file).endswith(".svg"):
continue
package_data_files.append(str(file))
setup(
name="sublime-music",
@@ -51,11 +55,7 @@ setup(
"ui/app_styles.css",
"ui/images/play-queue-play.png",
"adapters/images/default-album-art.png",
"dbus/mpris_specs/org.mpris.MediaPlayer2.xml",
"dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml",
"dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml",
"dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml",
*icon_filenames,
*package_data_files,
]
},
install_requires=[

View File

@@ -57,8 +57,9 @@ def main():
or os.environ.get("APPDATA")
or os.path.join("~/.config")
)
.joinpath("sublime-music", "config.json")
.expanduser()
.joinpath("sublime-music", "config.yaml")
.resolve()
)
app = SublimeMusicApp(Path(config_file))

View File

@@ -3,9 +3,11 @@ from .adapter_base import (
AlbumSearchQuery,
CacheMissError,
CachingAdapter,
ConfigParamDescriptor,
ConfigurationStore,
SongCacheStatus,
UIInfo,
)
from .configure_server_form import ConfigParamDescriptor, ConfigureServerForm
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
__all__ = (
@@ -15,8 +17,11 @@ __all__ = (
"CacheMissError",
"CachingAdapter",
"ConfigParamDescriptor",
"ConfigurationStore",
"ConfigureServerForm",
"DownloadProgress",
"Result",
"SearchResult",
"SongCacheStatus",
"UIInfo",
)

View File

@@ -1,21 +1,30 @@
import abc
import hashlib
import uuid
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
from pathlib import Path
from typing import (
Any,
cast,
Dict,
Iterable,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
)
from gi.repository import Gtk
try:
import keyring
keyring_imported = True
except Exception:
keyring_imported = False
from .api_objects import (
Album,
Artist,
@@ -152,47 +161,78 @@ class CacheMissError(Exception):
super().__init__(*args)
KEYRING_APP_NAME = "com.sumnerevans.SublimeMusic"
class ConfigurationStore(dict):
"""
This defines an abstract store for all configuration parameters for a given Adapter.
"""
MOCK = False
def __init__(self, **kwargs):
super().__init__(**kwargs)
def __repr__(self) -> str:
values = ", ".join(f"{k}={v!r}" for k, v in sorted(self.items()))
return f"ConfigurationStore({values})"
def get_secret(self, key: str) -> Any:
"""
Get the secret value in the store with the given key. If the key doesn't exist
in the store, return the default. This will retrieve the secret from whatever is
configured as the underlying secret storage mechanism so you don't have to deal
with secret storage yourself.
"""
value = self.get(key)
if not isinstance(value, (tuple, list)) or len(value) != 2:
return None
storage_type, storage_key = value
return {
"keyring": lambda: keyring.get_password(KEYRING_APP_NAME, storage_key),
"plaintext": lambda: storage_key,
}[storage_type]()
def set_secret(self, key: str, value: Any = None) -> Any:
"""
Set the secret value of the given key in the store. This should be used for
things such as passwords or API tokens. This will store the secret in whatever
is configured as the underlying secret storage mechanism so you don't have to
deal with secret storage yourself.
"""
if keyring_imported and not ConfigurationStore.MOCK:
try:
password_id = None
if password_type_and_id := self.get(key):
if cast(Tuple[str, str], password_type_and_id[0]) == "keyring":
password_id = password_type_and_id[1]
if password_id is None:
password_id = str(uuid.uuid4())
keyring.set_password(KEYRING_APP_NAME, password_id, value)
self[key] = ("keyring", password_id)
return
except Exception:
pass
self[key] = ("plaintext", 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 :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]
class UIInfo:
name: 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
icon_basename: str
icon_dir: Optional[Path] = None
def icon_name(self) -> str:
return f"{self.icon_basename}-symbolic"
def status_icon_name(self, status: str) -> str:
return f"{self.icon_basename}-{status.lower()}-symbolic"
class Adapter(abc.ABC):
@@ -205,45 +245,42 @@ class Adapter(abc.ABC):
"""
# Configuration and Initialization Properties
# These properties determine how the adapter can be configured and how to
# These functions determine how the adapter can be configured and how to
# initialize the adapter given those configuration values.
# ==================================================================================
@staticmethod
@abc.abstractmethod
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
def get_ui_info() -> UIInfo:
"""
Specifies the settings which can be configured for the adapter.
:returns: 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.
:returns: A :class:`UIInfo` object.
"""
@staticmethod
@abc.abstractmethod
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
"""
Specifies a function for verifying whether or not the config is valid.
This function should return a :class:`Gtk.Box` that gets any inputs required
from the user and uses the given ``config_store`` to store the configuration
values.
:param config: The adapter configuration. The keys of are the configuration
parameter names as defined by the return value of the
:class:`get_config_parameters` function. The values are the actual value of
the configuration parameter. It is guaranteed that all configuration
parameters that are marked as required will have a value in ``config``.
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.
:returns: A dictionary containing varification errors. The keys of the returned
dictionary should be the same as the passed in via the ``config`` parameter.
The values should be strings describing why the corresponding value in the
``config`` dictionary is invalid.
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.
"""
Not all keys need be returned (for example, if there's no error for a given
configuration parameter), and returning `None` indicates no error.
@staticmethod
@abc.abstractmethod
def migrate_configuration(config_store: ConfigurationStore):
"""
This function allows the adapter to migrate its configuration.
"""
@abc.abstractmethod
def __init__(self, config: dict, data_directory: Path):
def __init__(self, config_store: ConfigurationStore, data_directory: Path):
"""
This function should be overridden by inheritors of :class:`Adapter` and should
be used to do whatever setup is required for the adapter.
@@ -291,6 +328,14 @@ class Adapter(abc.ABC):
"""
return True
@property
@staticmethod
def can_be_ground_truth() -> bool:
"""
Whether or not this adapter can be used as a ground truth adapter.
"""
return True
# Network Properties
# These properties determine whether or not the adapter requires connection over a
# network and whether the underlying server can be pinged.

View File

@@ -0,0 +1,364 @@
"""
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 time import sleep
from typing import Any, Callable, cast, Dict, Iterable, Optional, Tuple, Type, Union
from gi.repository import 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[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, name="verify-config-spinner")
)
self.config_verification_box.add(
Gtk.Label(
label="<b>Verifying configuration...</b>", use_markup=True
)
)
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.DND)
)
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", "<b>Configuration is valid</b>"
)
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):
if secret:
self.config_store.set_secret(key, value)
else:
self.config_store[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,
error_text="<b>There are missing fields</b>\n"
"Please fill out all required fields.",
)
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__")
)
def verify_with_delay() -> Dict[str, Optional[str]]:
sleep(0.75)
if self._verification_status_ratchet != ratchet:
return {}
return self.verify_configuration()
errors_result: Result[Dict[str, Optional[str]]] = Result(verify_with_delay)
errors_result.add_done_callback(
lambda f: GLib.idle_add(on_verify_result, f.result())
)

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any, cast, Dict, Optional, Sequence, Set, Tuple, Union
from gi.repository import Gtk
from peewee import fn, prefetch
from sublime.adapters import api_objects as API
@@ -16,10 +17,12 @@ from .. import (
CacheMissError,
CachingAdapter,
ConfigParamDescriptor,
ConfigurationStore,
ConfigureServerForm,
SongCacheStatus,
UIInfo,
)
KEYS = CachingAdapter.CachedDataKey
@@ -31,16 +34,31 @@ class FilesystemAdapter(CachingAdapter):
# Configuration and Initialization Properties
# ==================================================================================
@staticmethod
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
return {
# TODO (#188): directory path, whether or not to scan tags
}
def get_ui_info() -> UIInfo:
return UIInfo(
name="Local Filesystem",
description="Add a directory on your local filesystem",
icon_basename="folder-music",
)
@staticmethod
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
return {
# TODO (#188): verify that the path exists
}
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
def verify_config_store() -> Dict[str, Optional[str]]:
return {}
return ConfigureServerForm(
config_store,
{
"directory": ConfigParamDescriptor(
type=Path, description="Music Directory", pathtype="directory"
)
},
verify_config_store,
)
@staticmethod
def migrate_configuration(config_store: ConfigurationStore):
pass
def __init__(
self, config: dict, data_directory: Path, is_cache: bool = False,
@@ -78,6 +96,7 @@ class FilesystemAdapter(CachingAdapter):
# Usage and Availability Properties
# ==================================================================================
can_be_cached = False # Can't be cached (there's no need).
can_be_ground_truth = False # TODO (#188)
is_networked = False # Doesn't access the network.
# TODO (#200) make these dependent on cache state. Need to do this kinda efficiently

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path class="error" fill="#ad2b05" d="M12 1.42A10.58 10.58 0 001.42 12 10.58 10.58 0 0012 22.58 10.58 10.58 0 0022.58 12 10.58 10.58 0 0012 1.42zM7.45 5.33L12 9.88l4.55-4.55 2.12 2.12L14.12 12l4.55 4.55-2.12 2.12L12 14.12l-4.55 4.55-2.12-2.12L9.88 12 5.33 7.45l2.12-2.12z"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path class="success" fill="#19ad05" d="M12 1.42A10.58 10.58 0 001.42 12 10.58 10.58 0 0012 22.58 10.58 10.58 0 0022.58 12 10.58 10.58 0 0012 1.42zm4.9 5.1L19 8.68l-8.63 8.49a1.5 1.5 0 01-2.1 0l-3.55-3.5 2.1-2.14 2.5 2.46z"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@@ -24,13 +24,14 @@ from typing import (
Sequence,
Set,
Tuple,
Type,
TypeVar,
Union,
)
import requests
from sublime.config import ProviderConfiguration
from .adapter_base import (
Adapter,
AlbumSearchQuery,
@@ -228,12 +229,6 @@ class AdapterManager:
_instance: Optional[_AdapterManagerInternal] = None
@staticmethod
def register_adapter(adapter_class: Type):
if not issubclass(adapter_class, Adapter):
raise TypeError("Attempting to register a class that is not an adapter.")
AdapterManager.available_adapters.add(adapter_class)
def __init__(self):
"""
This should not ever be called. You should only ever use the static methods on
@@ -287,30 +282,24 @@ class AdapterManager:
AdapterManager._offline_mode = config.offline_mode
# TODO (#197): actually do stuff with the config to determine which adapters to
# create, etc.
assert config.server is not None
source_data_dir = Path(config.cache_location, config.server.strhash())
assert config.provider is not None
assert isinstance(config.provider, ProviderConfiguration)
assert config.cache_location
source_data_dir = config.cache_location.joinpath(config.provider.id)
source_data_dir.joinpath("g").mkdir(parents=True, exist_ok=True)
source_data_dir.joinpath("c").mkdir(parents=True, exist_ok=True)
ground_truth_adapter_type = SubsonicAdapter
ground_truth_adapter = ground_truth_adapter_type(
{
key: getattr(config.server, key)
for key in ground_truth_adapter_type.get_config_parameters()
},
source_data_dir.joinpath("g"),
ground_truth_adapter = config.provider.ground_truth_adapter_type(
config.provider.ground_truth_adapter_config, source_data_dir.joinpath("g")
)
caching_adapter_type = FilesystemAdapter
caching_adapter = None
if caching_adapter_type and ground_truth_adapter_type.can_be_cached:
if (
caching_adapter_type := config.provider.caching_adapter_type
) and config.provider.ground_truth_adapter_type.can_be_cached:
caching_adapter = caching_adapter_type(
{
key: getattr(config.server, key)
for key in caching_adapter_type.get_config_parameters()
},
config.provider.caching_adapter_config,
source_data_dir.joinpath("c"),
is_cache=True,
)

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
@@ -23,9 +24,18 @@ from typing import (
from urllib.parse import urlencode, urlparse
import requests
from gi.repository import Gtk
from .api_objects import Directory, Response
from .. import Adapter, AlbumSearchQuery, api_objects as API, ConfigParamDescriptor
from .. import (
Adapter,
AlbumSearchQuery,
api_objects as API,
ConfigParamDescriptor,
ConfigurationStore,
ConfigureServerForm,
UIInfo,
)
try:
import gi
@@ -55,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
@@ -63,46 +79,105 @@ class SubsonicAdapter(Adapter):
# Configuration and Initialization Properties
# ==================================================================================
@staticmethod
def get_config_parameters() -> Dict[str, ConfigParamDescriptor]:
# TODO (#197) some way to test the connection to the server and a way to open
# the server URL in a browser
def get_ui_info() -> UIInfo:
return UIInfo(
name="Subsonic",
description="Connect to a Subsonic-compatible server",
icon_basename="subsonic",
icon_dir=Path(__file__).parent.joinpath("icons"),
)
@staticmethod
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
configs = {
"server_address": ConfigParamDescriptor(str, "Server address"),
"disable_cert_verify": ConfigParamDescriptor("password", "Password", False),
"server_address": ConfigParamDescriptor(str, "Server Address"),
"username": ConfigParamDescriptor(str, "Username"),
"password": ConfigParamDescriptor("password", "Password"),
"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,
"Sync Play 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.",
),
}
if networkmanager_imported:
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.",
),
}
)
return configs
def verify_configuration() -> Dict[str, Optional[str]]:
errors: Dict[str, Optional[str]] = {}
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)
@staticmethod
def verify_configuration(config: Dict[str, Any]) -> Dict[str, Optional[str]]:
errors: Dict[str, Optional[str]] = {}
def migrate_configuration(config_store: ConfigurationStore):
pass
# 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?
return errors
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:
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.
@@ -118,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
parsed_hostname = urlparse(self.hostname)
if not parsed_hostname.scheme:
self.hostname = "https://" + self.hostname
self.username = config["username"]
self.password = config["password"]
self.disable_cert_verify = config.get("disable_cert_verify")
self.password = config.get_secret("password")
self.verify_cert = config["verify_cert"]
self.is_shutting_down = False
@@ -271,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():
@@ -283,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:
@@ -332,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)
@@ -574,7 +651,9 @@ class SubsonicAdapter(Adapter):
self._get(
self._make_url("savePlayQueue"),
id=song_ids,
current=song_ids[current_song_index] if current_song_index else None,
current=song_ids[current_song_index]
if current_song_index is not None
else None,
position=math.floor(position.total_seconds() * 1000) if position else None,
)

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,6 +1,7 @@
import logging
import os
import random
import shutil
import sys
from datetime import timedelta
from functools import partial
@@ -39,10 +40,10 @@ from .adapters import (
SongCacheStatus,
)
from .adapters.api_objects import Playlist, PlayQueue, Song
from .config import AppConfiguration
from .config import AppConfiguration, ProviderConfiguration
from .dbus import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, Player, PlayerEvent
from .ui.configure_servers import ConfigureServersDialog
from .ui.configure_provider import ConfigureProviderDialog
from .ui.main import MainWindow
from .ui.state import RepeatType, UIState
@@ -74,7 +75,14 @@ class SublimeMusicApp(Gtk.Application):
self.add_action(action)
# Add action for menu items.
add_action("configure-servers", self.on_configure_servers)
add_action("add-new-music-provider", self.on_add_new_music_provider)
add_action("edit-current-music-provider", self.on_edit_current_music_provider)
add_action(
"switch-music-provider", self.on_switch_music_provider, parameter_type="s"
)
add_action(
"remove-music-provider", self.on_remove_music_provider, parameter_type="s"
)
# Add actions for player controls
add_action("play-pause", self.on_play_pause)
@@ -115,8 +123,17 @@ class SublimeMusicApp(Gtk.Application):
return
# Configure Icons
icon_dir = Path(__file__).parent.joinpath("ui", "icons")
Gtk.IconTheme.get_default().append_search_path(str(icon_dir))
default_icon_theme = Gtk.IconTheme.get_default()
for adapter in AdapterManager.available_adapters:
if icon_dir := adapter.get_ui_info().icon_dir:
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.
@@ -139,17 +156,21 @@ class SublimeMusicApp(Gtk.Application):
# Load the state for the server, if it exists.
self.app_config.load_state()
# If there is no current provider, use the first one if there are any
# configured, and if none are configured, then show the dialog to create a new
# one.
if self.app_config.provider is None:
if len(self.app_config.providers) == 0:
self.show_configure_servers_dialog()
# If they didn't add one with the dialog, close the window.
if len(self.app_config.providers) == 0:
self.window.close()
return
AdapterManager.reset(self.app_config, self.on_song_download_progress)
# If there is no current server, show the dialog to select a server.
if self.app_config.server is None:
self.show_configure_servers_dialog()
# If they didn't add one with the dialog, close the window.
if self.app_config.server is None:
self.window.close()
return
# Connect after we know there's a server configured.
self.window.stack.connect("notify::visible-child", self.on_stack_change)
self.window.connect("song-clicked", self.on_song_clicked)
@@ -274,7 +295,7 @@ class SublimeMusicApp(Gtk.Application):
GLib.timeout_add(10000, periodic_update)
# Prompt to load the play queue from the server.
if self.app_config.server.sync_enabled:
if AdapterManager.can_get_play_queue():
self.update_play_state_from_server(prompt_confirm=True)
# Send out to the bus that we exist.
@@ -527,9 +548,45 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.current_notification = None
self.update_window()
def on_configure_servers(self, *args):
def on_add_new_music_provider(self, *args):
self.show_configure_servers_dialog()
def on_edit_current_music_provider(self, *args):
self.show_configure_servers_dialog(self.app_config.provider)
def on_switch_music_provider(self, _, provider_id: GLib.Variant):
if self.app_config.state.playing:
self.on_play_pause()
self.app_config.save()
self.app_config.current_provider_id = provider_id.get_string()
self.reset_state()
self.app_config.save()
def on_remove_music_provider(self, _, provider_id: GLib.Variant):
provider = self.app_config.providers[provider_id.get_string()]
confirm_dialog = Gtk.MessageDialog(
transient_for=self.window,
message_type=Gtk.MessageType.WARNING,
buttons=(
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
Gtk.STOCK_DELETE,
Gtk.ResponseType.YES,
),
text=f"Are you sure you want to delete the {provider.name} music provider?",
)
confirm_dialog.format_secondary_markup(
"Deleting this music provider will delete all cached songs and metadata "
"associated with this provider."
)
if confirm_dialog.run() == Gtk.ResponseType.YES:
assert self.app_config.cache_location
provider_dir = self.app_config.cache_location.joinpath(provider.id)
shutil.rmtree(str(provider_dir))
del self.app_config.providers[provider.id]
confirm_dialog.destroy()
def on_window_go_to(self, win: Any, action: str, value: str):
{
"album": self.on_go_to_album,
@@ -679,25 +736,12 @@ class SublimeMusicApp(Gtk.Application):
def on_go_online(self, *args):
self.on_refresh_window(None, {"__settings__": {"offline_mode": False}})
def on_server_list_changed(self, action: Any, servers: GLib.Variant):
self.app_config.servers = servers
self.app_config.save()
def on_connected_server_changed(
self, action: Any, current_server_index: int,
):
if self.app_config.server:
self.app_config.save()
self.app_config.current_server_index = current_server_index
self.app_config.save()
self.reset_state()
def reset_state(self):
if self.app_config.state.playing:
self.on_play_pause()
self.loading_state = True
self.player.reset()
AdapterManager.reset(self.app_config, self.on_song_download_progress)
self.loading_state = False
# Update the window according to the new server configuration.
@@ -876,7 +920,7 @@ class SublimeMusicApp(Gtk.Application):
if tap_imported and self.tap:
self.tap.stop()
if self.app_config.server is None:
if self.app_config.provider is None:
return
if self.player:
@@ -891,12 +935,28 @@ class SublimeMusicApp(Gtk.Application):
AdapterManager.shutdown()
# ########## HELPER METHODS ########## #
def show_configure_servers_dialog(self):
def show_configure_servers_dialog(
self, provider_config: Optional[ProviderConfiguration] = None,
):
"""Show the Connect to Server dialog."""
dialog = ConfigureServersDialog(self.window, self.app_config)
dialog.connect("server-list-changed", self.on_server_list_changed)
dialog.connect("connected-server-changed", self.on_connected_server_changed)
dialog.run()
dialog = ConfigureProviderDialog(self.window, provider_config)
result = dialog.run()
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
if provider_id == self.app_config.current_provider_id:
# Just update the window.
self.update_window()
else:
# Switch to the new provider.
if self.app_config.state.playing:
self.on_play_pause()
self.app_config.current_provider_id = provider_id
self.app_config.save()
self.update_window(force=True)
dialog.destroy()
def update_window(self, force: bool = False):
@@ -1267,7 +1327,7 @@ class SublimeMusicApp(Gtk.Application):
def save_play_queue(self, song_playing_order_token: int = None):
if (
len(self.app_config.state.play_queue) == 0
or self.app_config.server is None
or self.app_config.provider is None
or (
song_playing_order_token
and song_playing_order_token != self.song_playing_order_token
@@ -1278,7 +1338,7 @@ class SublimeMusicApp(Gtk.Application):
position = self.app_config.state.song_progress
self.last_play_queue_update = position or timedelta(0)
if self.app_config.server.sync_enabled and self.app_config.state.current_song:
if AdapterManager.can_save_play_queue() and self.app_config.state.current_song:
AdapterManager.save_play_queue(
song_ids=self.app_config.state.play_queue,
current_song_index=self.app_config.state.current_song_index,

View File

@@ -1,17 +1,32 @@
import hashlib
import logging
import os
import pickle
from dataclasses import asdict, dataclass, field, fields
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import List, Optional
from typing import Any, cast, Dict, Optional, Type
import yaml
import dataclasses_json
from dataclasses_json import config, DataClassJsonMixin
from sublime.adapters import ConfigurationStore
from sublime.ui.state import UIState
# JSON decoder and encoder translations
def encode_path(path: Path) -> str:
return str(path.resolve())
dataclasses_json.cfg.global_config.decoders[Path] = Path
dataclasses_json.cfg.global_config.decoders[Optional[Path]] = (
lambda p: Path(p) if p else None
)
dataclasses_json.cfg.global_config.encoders[Path] = encode_path
dataclasses_json.cfg.global_config.encoders[Optional[Path]] = encode_path
class ReplayGainType(Enum):
NO = 0
TRACK = 1
@@ -31,54 +46,86 @@ class ReplayGainType(Enum):
@dataclass
class ServerConfiguration:
name: str = "Default"
server_address: str = "http://yourhost"
local_network_address: str = ""
local_network_ssid: str = ""
username: str = ""
password: str = ""
sync_enabled: bool = True
disable_cert_verify: bool = False
version: int = 0
class ProviderConfiguration:
id: str
name: str
ground_truth_adapter_type: Type
ground_truth_adapter_config: ConfigurationStore
caching_adapter_type: Optional[Type] = None
caching_adapter_config: Optional[ConfigurationStore] = None
def migrate(self):
self.version = 0
self.ground_truth_adapter_type.migrate_configuration(
self.ground_truth_adapter_config
)
if self.caching_adapter_type:
self.caching_adapter_type.migrate_configuration(self.caching_adapter_config)
_strhash: Optional[str] = None
def strhash(self) -> str:
# TODO (#197): make this configurable by the adapters the combination of the
# hashes will be the hash dir
"""
Returns the MD5 hash of the server's name, server address, and
username. This should be used whenever it's necessary to uniquely
identify the server, rather than using the name (which is not
necessarily unique).
def encode_providers(
providers_dict: Dict[str, Dict[str, Any]]
) -> Dict[str, Dict[str, Any]]:
return {
id_: {
**config,
"ground_truth_adapter_type": config["ground_truth_adapter_type"].__name__,
"caching_adapter_type": (
cast(type, config.get("caching_adapter_type")).__name__
if config.get("caching_adapter_type")
else None
),
}
for id_, config in providers_dict.items()
}
>>> sc = ServerConfiguration(
... name='foo',
... server_address='bar',
... username='baz',
... )
>>> sc.strhash()
'6df23dc03f9b54cc38a0fc1483df6e21'
"""
if not self._strhash:
server_info = self.name + self.server_address + self.username
self._strhash = hashlib.md5(server_info.encode("utf-8")).hexdigest()
return self._strhash
def decode_providers(
providers_dict: Dict[str, Dict[str, Any]]
) -> Dict[str, ProviderConfiguration]:
from sublime.adapters import AdapterManager
def find_adapter_type(type_name: str) -> Type:
for available_adapter in AdapterManager.available_adapters:
if available_adapter.__name__ == type_name:
return available_adapter
raise Exception(f"Couldn't find adapter of type {type_name}")
return {
id_: ProviderConfiguration(
config["id"],
config["name"],
ground_truth_adapter_type=find_adapter_type(
config["ground_truth_adapter_type"]
),
ground_truth_adapter_config=ConfigurationStore(
**config["ground_truth_adapter_config"]
),
caching_adapter_type=(
find_adapter_type(cat)
if (cat := config.get("caching_adapter_type"))
else None
),
caching_adapter_config=(
ConfigurationStore(**config.get("caching_adapter_config", {}))
),
)
for id_, config in providers_dict.items()
}
@dataclass
class AppConfiguration:
version: int = 3
cache_location: str = ""
class AppConfiguration(DataClassJsonMixin):
version: int = 5
cache_location: Optional[Path] = None
filename: Optional[Path] = None
# Servers
servers: List[ServerConfiguration] = field(default_factory=list)
current_server_index: int = -1
# Providers
providers: Dict[str, ProviderConfiguration] = field(
default_factory=dict,
metadata=config(encoder=encode_providers, decoder=decode_providers),
)
current_provider_id: Optional[str] = None
_loaded_provider_id: Optional[str] = field(default=None, init=False)
# Global Settings
song_play_notification: bool = True
@@ -96,19 +143,15 @@ class AppConfiguration:
@staticmethod
def load_from_file(filename: Path) -> "AppConfiguration":
args = {}
config = AppConfiguration()
try:
if filename.exists():
with open(filename, "r") as f:
field_names = {f.name for f in fields(AppConfiguration)}
args = yaml.load(f, Loader=yaml.CLoader).items()
args = dict(filter(lambda kv: kv[0] in field_names, args))
config = AppConfiguration.from_json(f.read())
except Exception:
pass
config = AppConfiguration(**args)
config.filename = filename
return config
def __post_init__(self):
@@ -116,82 +159,68 @@ class AppConfiguration:
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 = path.as_posix()
# Deserialize the YAML into the ServerConfiguration object.
if len(self.servers) > 0 and type(self.servers[0]) != ServerConfiguration:
self.servers = [ServerConfiguration(**sc) for sc in self.servers]
self.cache_location = path
self._state = None
self._current_server_hash = None
self._loaded_provider_id = None
self.migrate()
def migrate(self):
for server in self.servers:
server.migrate()
for _, provider in self.providers.items():
provider.migrate()
if self.version < 4:
self.allow_song_downloads = not self.always_stream
self.version = 4
self.version = 5
self.state.migrate()
@property
def server(self) -> Optional[ServerConfiguration]:
if 0 <= self.current_server_index < len(self.servers):
return self.servers[self.current_server_index]
return None
def provider(self) -> Optional[ProviderConfiguration]:
return self.providers.get(self.current_provider_id or "")
@property
def state(self) -> UIState:
server = self.server
if not server:
if not (provider := self.provider):
return UIState()
# If the server has changed, then retrieve the new server's state.
if self._current_server_hash != server.strhash():
# If the provider has changed, then retrieve the new provider's state.
if self._loaded_provider_id != provider.id:
self.load_state()
return self._state
def load_state(self):
self._state = UIState()
if not self.server:
if not (provider := self.provider):
return
self._current_server_hash = self.server.strhash()
if (
state_file_location := self.state_file_location
) and state_file_location.exists():
self._loaded_provider_id = provider.id
if (state_filename := self._state_file_location) and state_filename.exists():
try:
with open(state_file_location, "rb") as f:
with open(state_filename, "rb") as f:
self._state = pickle.load(f)
except Exception:
logging.warning(f"Couldn't load state from {state_file_location}")
logging.exception(f"Couldn't load state from {state_filename}")
# Just ignore any errors, it is only UI state.
self._state = UIState()
@property
def state_file_location(self) -> Optional[Path]:
if self.server is None:
def _state_file_location(self) -> Optional[Path]:
if not (provider := self.provider):
return None
server_hash = self.server.strhash()
state_file_location = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
return state_file_location.expanduser().joinpath(
"sublime-music", server_hash, "state.pickle"
state_filename = Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share")
return state_filename.expanduser().joinpath(
"sublime-music", provider.id, "state.pickle"
)
def save(self):
# Save the config as YAML.
self.filename.parent.mkdir(parents=True, exist_ok=True)
json = self.to_json(indent=2, sort_keys=True)
with open(self.filename, "w+") as f:
f.write(yaml.dump(asdict(self)))
f.write(json)
# Save the state for the current server.
if state_file_location := self.state_file_location:
state_file_location.parent.mkdir(parents=True, exist_ok=True)
with open(state_file_location, "wb+") as f:
# Save the state for the current provider.
if state_filename := self._state_file_location:
state_filename.parent.mkdir(parents=True, exist_ok=True)
with open(state_filename, "wb+") as f:
pickle.dump(self.state, f)

View File

@@ -304,9 +304,7 @@ class AlbumsPanel(Gtk.Box):
self.populate_genre_combo(app_config, force=force)
# At this point, the current query should be totally updated.
self.grid_order_token = self.grid.update_params(
self.current_query, self.offline_mode
)
self.grid_order_token = self.grid.update_params(app_config)
self.grid.update(self.grid_order_token, app_config, force=force)
def _get_opposite_sort_dir(self, sort_dir: str) -> str:
@@ -519,16 +517,25 @@ class AlbumsGrid(Gtk.Overlay):
page: int = 0
num_pages: Optional[int] = None
next_page_fn = None
server_hash: Optional[str] = None
provider_id: Optional[str] = None
def update_params(self, query: AlbumSearchQuery, offline_mode: bool) -> int:
def update_params(self, app_config: AppConfiguration) -> int:
# If there's a diff, increase the ratchet.
if self.current_query.strhash() != query.strhash():
if (
self.current_query.strhash()
!= (search_query := app_config.state.current_album_search_query).strhash()
):
self.order_ratchet += 1
self.current_query = query
if offline_mode != self.offline_mode:
self.current_query = search_query
if self.offline_mode != (offline_mode := app_config.offline_mode):
self.order_ratchet += 1
self.offline_mode = offline_mode
self.offline_mode = offline_mode
if self.provider_id != (provider_id := app_config.current_provider_id):
self.order_ratchet += 1
self.provider_id = provider_id
return self.order_ratchet
def __init__(self, *args, **kwargs):
@@ -620,11 +627,6 @@ class AlbumsGrid(Gtk.Overlay):
self.page_size = app_config.state.album_page_size
self.page = app_config.state.album_page
new_hash = server.strhash() if (server := app_config.server) else None
if self.server_hash != new_hash:
self.order_ratchet += 1
self.server_hash = new_hash
self.update_grid(
order_token,
use_ground_truth_adapter=force,
@@ -651,7 +653,7 @@ class AlbumsGrid(Gtk.Overlay):
force_grid_reload_from_master = (
force_grid_reload_from_master
or use_ground_truth_adapter
or self.latest_applied_order_ratchet < self.order_ratchet
or self.latest_applied_order_ratchet < order_token
)
def do_update_grid(selected_index: Optional[int]):
@@ -669,7 +671,7 @@ class AlbumsGrid(Gtk.Overlay):
# Don't override more recent results
if order_token < self.latest_applied_order_ratchet:
return
self.latest_applied_order_ratchet = self.order_ratchet
self.latest_applied_order_ratchet = order_token
is_partial = False
try:
@@ -681,6 +683,7 @@ class AlbumsGrid(Gtk.Overlay):
if self.error_dialog:
self.spinner.hide()
return
# TODO (#122): make this non-modal
self.error_dialog = Gtk.MessageDialog(
transient_for=self.get_toplevel(),
message_type=Gtk.MessageType.ERROR,
@@ -732,10 +735,7 @@ class AlbumsGrid(Gtk.Overlay):
)
do_update_grid(selected_index)
if (
use_ground_truth_adapter
or self.latest_applied_order_ratchet < self.order_ratchet
):
if force_grid_reload_from_master:
albums_result = AdapterManager.get_albums(
self.current_query, use_ground_truth_adapter=use_ground_truth_adapter
)

View File

@@ -25,7 +25,7 @@
#current-downloads-list {
min-height: 30px;
min-width: 250px;
min-width: 350px;
}
#current-downloads-list-placeholder {
@@ -64,6 +64,32 @@
min-width: 230px;
}
/* ********** Configure Provider Dialog ********** */
#ground-truth-adapter-options-list {
margin: 0 40px;
}
#music-source-config-name-entry-grid {
margin: 10px 0;
}
#config-verification-separator {
margin: 5px -10px;
}
#verify-config-spinner {
min-height: 32px;
min-width: 32px;
}
.configure-form-help-icon {
margin-left: 10px;
}
entry.invalid {
border-color: red;
}
/* ********** Playlist ********** */
#playlist-list-listbox row {
margin: 0;

View File

@@ -441,9 +441,9 @@ class MusicDirectoryList(Gtk.Box):
)
)
icon = Gio.ThemedIcon(name="go-next-symbolic")
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
image = Gtk.Image.new_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON)
rowbox.pack_end(image, False, False, 5)
row.add(rowbox)
row.show_all()
return row

View File

@@ -8,6 +8,8 @@ NumericFieldDescription = Tuple[str, str, Tuple[int, int, int], int]
OptionFieldDescription = Tuple[str, str, Tuple[str, ...]]
# TODO (#233) get rid of this and just make a nice custom one for Playlists since I am
# not using this anywhere else anymore.
class EditFormDialog(Gtk.Dialog):
entity_name: str
title: str

View File

@@ -19,7 +19,7 @@ class IconButton(Gtk.Button):
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)
box.pack_start(self.image, False, False, 0)
if label is not None:
box.add(Gtk.Label(label=label))

View File

@@ -0,0 +1,228 @@
import uuid
from enum import Enum
from typing import Any, Optional, Type
from gi.repository import Gio, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, UIInfo
from sublime.adapters.filesystem import FilesystemAdapter
from sublime.config import ConfigurationStore, ProviderConfiguration
class AdapterTypeModel(GObject.GObject):
adapter_type = GObject.Property(type=object)
def __init__(self, adapter_type: Type):
GObject.GObject.__init__(self)
self.adapter_type = adapter_type
class DialogStage(Enum):
SELECT_ADAPTER = "select"
CONFIGURE_ADAPTER = "configure"
class ConfigureProviderDialog(Gtk.Dialog):
_current_index = -1
stage = DialogStage.SELECT_ADAPTER
def set_title(self, editing: bool, provider_config: ProviderConfiguration = None):
if editing:
assert provider_config is not None
title = f"Edit {provider_config.name}"
else:
title = "Add New Music Source"
self.header.props.title = title
def __init__(self, parent: Any, provider_config: Optional[ProviderConfiguration]):
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
self.provider_config = provider_config
self.editing = provider_config is not None
self.set_default_size(400, 350)
# HEADER
self.header = Gtk.HeaderBar()
self.set_title(self.editing, provider_config)
self.cancel_back_button = Gtk.Button(label="Cancel")
self.cancel_back_button.connect("clicked", self._on_cancel_back_clicked)
self.header.pack_start(self.cancel_back_button)
self.next_add_button = Gtk.Button(label="Edit" if self.editing else "Next")
self.next_add_button.get_style_context().add_class("suggested-action")
self.next_add_button.connect("clicked", self._on_next_add_clicked)
self.header.pack_end(self.next_add_button)
self.set_titlebar(self.header)
content_area = self.get_content_area()
self.stack = Gtk.Stack()
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
# ADAPTER TYPE OPTIONS
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", 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()
row = Gtk.ListBoxRow()
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
rowbox.pack_start(
Gtk.Image.new_from_icon_name(ui_info.icon_name(), Gtk.IconSize.DND),
False,
False,
5,
)
rowbox.add(
Gtk.Label(
label=f"<b>{ui_info.name}</b>\n{ui_info.description}",
use_markup=True,
margin=8,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
)
)
row.add(rowbox)
row.show_all()
return row
self.adapter_options_list.bind_model(self.adapter_type_store, create_row)
available_ground_truth_adapters = filter(
lambda a: a.can_be_ground_truth, AdapterManager.available_adapters
)
for adapter_type in sorted(
available_ground_truth_adapters, key=lambda a: a.get_ui_info().name
):
self.adapter_type_store.append(AdapterTypeModel(adapter_type))
adapter_type_box.pack_start(self.adapter_options_list, True, True, 10)
self.stack.add_named(adapter_type_box, "select")
self.configure_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.stack.add_named(self.configure_box, "configure")
content_area.pack_start(self.stack, True, True, 0)
self.show_all()
if self.editing:
assert self.provider_config
for i, adapter_type in enumerate(self.adapter_type_store):
if (
adapter_type.adapter_type
== self.provider_config.ground_truth_adapter_type
):
row = self.adapter_options_list.get_row_at_index(i)
self.adapter_options_list.select_row(row)
break
self._name_is_valid = True
self._on_next_add_clicked()
def _on_cancel_back_clicked(self, _):
if self.stage == DialogStage.SELECT_ADAPTER:
self.close()
else:
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, *args):
if self.stage == DialogStage.SELECT_ADAPTER:
index = self.adapter_options_list.get_selected_row().get_index()
if index != self._current_index:
for c in self.configure_box.get_children():
self.configure_box.remove(c)
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()
)
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._adapter_config_is_valid = False
self.stack.set_visible_child_name("configure")
self.stage = DialogStage.CONFIGURE_ADAPTER
self.cancel_back_button.set_label("Change Type" if self.editing else "Back")
self.next_add_button.set_label("Edit" if self.editing else "Add")
self.next_add_button.set_sensitive(
index == self._current_index and self._adapter_config_is_valid
)
self._current_index = index
else:
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:
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)
if self.editing:
assert self.provider_config
self.provider_config.name = entry.get_text()
self.set_title(self.editing, self.provider_config)
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

@@ -1,255 +0,0 @@
import subprocess
from typing import Any
from gi.repository import GObject, Gtk
from sublime.config import AppConfiguration, ServerConfiguration
from sublime.ui.common import EditFormDialog, IconButton
class EditServerDialog(EditFormDialog):
entity_name: str = "Server"
initial_size = (450, 250)
text_fields = [
("Name", "name", False),
("Server address", "server_address", False),
("Local network address", "local_network_address", False),
("Local network SSID", "local_network_ssid", False),
("Username", "username", False),
("Password", "password", True),
]
boolean_fields = [
("Play queue sync enabled", "sync_enabled"),
("Do not verify certificate", "disable_cert_verify"),
]
def __init__(self, *args, **kwargs):
test_server = Gtk.Button(label="Test Connection to Server")
# test_server.connect("clicked", self.on_test_server_clicked)
open_in_browser = Gtk.Button(label="Open in Browser")
open_in_browser.connect("clicked", self.on_open_in_browser_clicked)
self.extra_buttons = [(test_server, None), (open_in_browser, None)]
super().__init__(*args, **kwargs)
# TODO (#197) figure out how to do this
# def on_test_server_clicked(self, event: Any):
# # Instantiate the server.
# server_address = self.data["server_address"].get_text()
# server = Server(
# name=self.data["name"].get_text(),
# hostname=server_address,
# username=self.data["username"].get_text(),
# password=self.data["password"].get_text(),
# disable_cert_verify=self.data["disable_cert_verify"].get_active(),
# )
# # Try to ping, and show a message box with whether or not it worked.
# try:
# server.ping()
# dialog = Gtk.MessageDialog(
# transient_for=self,
# message_type=Gtk.MessageType.INFO,
# buttons=Gtk.ButtonsType.OK,
# text="Connection to server successful.",
# )
# dialog.format_secondary_markup(
# f"Connection to {server_address} successful."
# )
# except Exception as err:
# dialog = Gtk.MessageDialog(
# transient_for=self,
# message_type=Gtk.MessageType.ERROR,
# buttons=Gtk.ButtonsType.OK,
# text="Connection to server unsuccessful.",
# )
# dialog.format_secondary_markup(
# f"Connection to {server_address} resulted in the following "
# f"error:\n\n{err}"
# )
# dialog.run()
# dialog.destroy()
def on_open_in_browser_clicked(self, event: Any):
subprocess.call(["xdg-open", self.data["server_address"].get_text()])
class ConfigureServersDialog(Gtk.Dialog):
__gsignals__ = {
"server-list-changed": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object,),
),
"connected-server-changed": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object,),
),
}
def __init__(self, parent: Any, config: AppConfiguration):
Gtk.Dialog.__init__(
self,
title="Configure Servers",
transient_for=parent,
flags=0,
add_buttons=(),
)
self.server_configs = config.servers
self.selected_server_index = config.current_server_index
self.set_default_size(500, 300)
# Flow box to hold the server list and the buttons.
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Server List
self.server_list = Gtk.ListBox(activate_on_single_click=False)
self.server_list.connect(
"selected-rows-changed", self.server_list_on_selected_rows_changed
)
self.server_list.connect("row-activated", self.on_server_list_activate)
flowbox.pack_start(self.server_list, True, True, 10)
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# Tuples: (button, action function, pack side, requires_selection)
# Add all of the buttons to the button box.
self.buttons = [
(
IconButton("document-edit-symbolic", label="Edit...", relief=True,),
lambda e: self.on_edit_clicked(False),
"start",
True,
),
(
IconButton("list-add-symbolic", label="Add...", relief=True,),
lambda e: self.on_edit_clicked(True),
"start",
False,
),
(
IconButton("list-remove-symbolic", label="Remove", relief=True,),
self.on_remove_clicked,
"start",
True,
),
(
IconButton("window-close-symbolic", label="Close", relief=True,),
lambda _: self.close(),
"end",
False,
),
(
IconButton(
"network-transmit-receive-symbolic", label="Connect", relief=True,
),
self.on_connect_clicked,
"end",
True,
),
]
for button_cfg in self.buttons:
btn, action, pack_end, requires_selection = button_cfg
if pack_end == "end":
button_box.pack_end(btn, False, True, 5)
else:
button_box.pack_start(btn, False, True, 5)
btn.connect("clicked", action)
flowbox.pack_end(button_box, False, False, 0)
# Add the flowbox to the dialog and show the dialog.
content_area = self.get_content_area()
content_area.pack_start(flowbox, True, True, 10)
self.show_all()
self.refresh_server_list()
self.server_list_on_selected_rows_changed(None)
def refresh_server_list(self):
# Clear out the list.
for el in self.server_list:
self.server_list.remove(el)
# Add all of the rows for each of the servers.
for i, config in enumerate(self.server_configs):
box = Gtk.Box()
image = Gtk.Image(margin=5)
if i == self.selected_server_index:
image.set_from_icon_name(
"network-transmit-receive-symbolic", Gtk.IconSize.SMALL_TOOLBAR,
)
box.add(image)
server_name_label = Gtk.Label(label=config.name)
server_name_label.set_halign(Gtk.Align.START)
box.add(server_name_label)
self.server_list.add(box)
# Show them, and select the current server.
self.show_all()
if self.selected_server_index is not None and self.selected_server_index >= 0:
self.server_list.select_row(
self.server_list.get_row_at_index(self.selected_server_index)
)
def on_remove_clicked(self, event: Any):
selected = self.server_list.get_selected_row()
if selected:
del self.server_configs[selected.get_index()]
self.refresh_server_list()
self.emit("server-list-changed", self.server_configs)
def on_edit_clicked(self, add: bool):
if add:
dialog = EditServerDialog(self)
else:
selected_index = self.server_list.get_selected_row().get_index()
dialog = EditServerDialog(self, self.server_configs[selected_index])
result = dialog.run()
if result == Gtk.ResponseType.OK:
# Create a new server configuration to use.
new_config = ServerConfiguration(
name=dialog.data["name"].get_text(),
server_address=dialog.data["server_address"].get_text(),
local_network_address=dialog.data["local_network_address"].get_text(),
local_network_ssid=dialog.data["local_network_ssid"].get_text(),
username=dialog.data["username"].get_text(),
password=dialog.data["password"].get_text(),
sync_enabled=dialog.data["sync_enabled"].get_active(),
disable_cert_verify=dialog.data["disable_cert_verify"].get_active(),
)
if add:
self.server_configs.append(new_config)
else:
self.server_configs[selected_index] = new_config
self.refresh_server_list()
self.emit("server-list-changed", self.server_configs)
dialog.destroy()
def on_server_list_activate(self, *args):
self.on_connect_clicked(None)
def on_connect_clicked(self, event: Any):
selected_index = self.server_list.get_selected_row().get_index()
self.emit("connected-server-changed", selected_index)
self.close()
def server_list_on_selected_rows_changed(self, event: Any):
# Update the state of the buttons depending on whether or not a row is
# selected in the server list.
has_selection = self.server_list.get_selected_row()
for button, *_, requires_selection in self.buttons:
button.set_sensitive(bool(not requires_selection or has_selection))

View File

@@ -9,7 +9,7 @@ from sublime.adapters import (
DownloadProgress,
Result,
)
from sublime.config import AppConfiguration, ReplayGainType
from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType
from sublime.ui import albums, artists, browse, player_controls, playlists, util
from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage
@@ -108,6 +108,7 @@ class MainWindow(Gtk.ApplicationWindow):
self.connect("button-release-event", self._on_button_release)
current_notification_hash = None
current_other_providers: Tuple[ProviderConfiguration, ...] = ()
def update(self, app_config: AppConfiguration, force: bool = False):
notification = app_config.state.current_notification
@@ -137,10 +138,13 @@ class MainWindow(Gtk.ApplicationWindow):
self.notification_revealer.set_reveal_child(False)
# Update the Connected to label on the popup menu.
if app_config.server:
self.connected_to_label.set_markup(f"<b>{app_config.server.name}</b>")
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 = ""
@@ -151,16 +155,18 @@ class MainWindow(Gtk.ApplicationWindow):
else:
status_label = "Error Connecting to Server"
icon_status = status_label.split()[0].lower()
self.server_connection_menu_button.set_icon(
f"server-subsonic-{status_label.split()[0].lower()}-symbolic"
f"{icon_basename}-{icon_status}-symbolic"
)
self.connection_status_icon.set_from_icon_name(
f"server-{status_label.split()[0].lower()}-symbolic",
Gtk.IconSize.BUTTON,
f"server-{icon_status}-symbolic", Gtk.IconSize.BUTTON,
)
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
@@ -169,6 +175,24 @@ class MainWindow(Gtk.ApplicationWindow):
offline_mode = app_config.offline_mode
self.offline_mode_switch.set_active(offline_mode)
# Switch Provider options
other_providers = tuple(
v
for k, v in app_config.providers.items()
if k != app_config.current_provider_id
)
if self.current_other_providers != other_providers:
self.current_other_providers = other_providers
for c in self.provider_options_box.get_children():
self.provider_options_box.remove(c)
for provider in sorted(other_providers, key=lambda p: p.name.lower()):
self.provider_options_box.pack_start(
self._create_switch_provider_button(provider), False, True, 0,
)
self.provider_options_box.show_all()
# Main Settings
self.notification_switch.set_active(app_config.song_play_notification)
@@ -402,7 +426,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,
)
@@ -448,14 +472,50 @@ class MainWindow(Gtk.ApplicationWindow):
return box, switch
def _create_model_button(
self, text: str, clicked_fn: Callable = None, **kwargs
self,
text: str,
clicked_fn: Callable = None,
action_name: str = None,
action_value: GLib.Variant = None,
**kwargs,
) -> Gtk.ModelButton:
model_button = Gtk.ModelButton(text=text, **kwargs)
model_button.get_style_context().add_class("menu-button")
if clicked_fn:
model_button.connect("clicked", clicked_fn)
if action_name:
model_button.set_action_name(f"app.{action_name}")
if action_value is not None:
model_button.set_action_target_value(action_value)
return model_button
def _create_switch_provider_button(
self, provider: ProviderConfiguration
) -> Gtk.Box:
box = Gtk.Box()
provider_name_button = self._create_model_button(
provider.name,
action_name="switch-music-provider",
action_value=GLib.Variant("s", provider.id),
)
provider_name_button.connect(
"clicked", lambda *a: self.server_connection_popover.popdown()
)
box.pack_start(provider_name_button, True, True, 0)
provider_delete_button = IconButton(
icon_name="user-trash-symbolic",
tooltip_text=f"Remove the {provider.name} music provider",
)
provider_delete_button.connect(
"clicked", lambda *a: self.server_connection_popover.popdown()
)
provider_delete_button.set_action_name("app.remove-music-provider")
provider_delete_button.set_action_target_value(GLib.Variant("s", provider.id))
box.pack_end(provider_delete_button, False, False, 0)
return box
def _create_spin_button_menu_item(
self, label: str, low: int, high: int, step: int, settings_name: str
) -> Tuple[Gtk.Box, Gtk.Entry]:
@@ -522,7 +582,7 @@ class MainWindow(Gtk.ApplicationWindow):
("Delete Cached Song Files and Metadata", self._clear_entire_cache),
]
for text, clicked_fn in menu_items:
clear_song_cache = self._create_model_button(text, clicked_fn)
clear_song_cache = self._create_model_button(text, clicked_fn=clicked_fn)
clear_cache_options.pack_start(clear_song_cache, False, True, 0)
menu.add(clear_cache_options)
@@ -569,27 +629,37 @@ class MainWindow(Gtk.ApplicationWindow):
vbox.add(offline_box)
edit_button = self._create_model_button(
"Edit Configuration...", self._on_edit_configuration_click
"Edit Configuration...", action_name="edit-current-music-provider"
)
vbox.add(edit_button)
vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
music_provider_button = self._create_model_button(
"Switch Music Provider",
self._on_switch_provider_click,
menu_name="switch-provider",
"Switch Music Provider", menu_name="switch-provider",
)
# TODO (#197)
music_provider_button.set_action_name("app.configure-servers")
vbox.add(music_provider_button)
add_new_music_provider_button = self._create_model_button(
"Add New Music Provider...", self._on_add_new_provider_click
"Add New Music Provider...", action_name="add-new-music-provider"
)
vbox.add(add_new_music_provider_button)
menu.add(vbox)
# Create the "Switch Provider" sub-menu.
switch_provider_options = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# Back button
switch_provider_options.add(
Gtk.ModelButton(inverted=True, centered=True, menu_name="main")
)
# Provider Options box
self.provider_options_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
switch_provider_options.add(self.provider_options_box)
menu.add(switch_provider_options)
menu.child_set_property(switch_provider_options, "submenu", "switch-provider")
return menu
def _create_main_menu(self) -> Gtk.PopoverMenu:
@@ -768,15 +838,14 @@ class MainWindow(Gtk.ApplicationWindow):
confirm_dialog = Gtk.MessageDialog(
transient_for=self.get_toplevel(),
message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.NONE,
buttons=(
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
Gtk.STOCK_DELETE,
Gtk.ResponseType.YES,
),
text=title,
)
confirm_dialog.add_buttons(
Gtk.STOCK_DELETE,
Gtk.ResponseType.YES,
Gtk.STOCK_CANCEL,
Gtk.ResponseType.CANCEL,
)
confirm_dialog.format_secondary_markup(detail_text)
result = confirm_dialog.run()
confirm_dialog.destroy()
@@ -813,18 +882,6 @@ class MainWindow(Gtk.ApplicationWindow):
{"replay_gain": ReplayGainType.from_string(combo.get_active_id())}
)
def _on_edit_configuration_click(self, _):
# TODO (#197): EDIT
pass
def _on_switch_provider_click(self, _):
# TODO (#197): switch
pass
def _on_add_new_provider_click(self, _):
# TODO (#197) add new
pass
def _on_search_entry_focus(self, *args):
self._show_search()

View File

@@ -3,21 +3,35 @@ from time import sleep
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.adapters import AdapterManager, ConfigurationStore, Result, SearchResult
from sublime.adapters.filesystem import FilesystemAdapter
from sublime.adapters.subsonic import api_objects as SubsonicAPI, SubsonicAdapter
from sublime.config import AppConfiguration, ProviderConfiguration
@pytest.fixture
def adapter_manager(tmp_path: Path):
ConfigurationStore.MOCK = True
subsonic_config_store = ConfigurationStore(
server_address="https://subsonic.example.com",
username="test",
verify_cert=True,
)
subsonic_config_store.set_secret("password", "testpass")
config = AppConfiguration(
servers=[
ServerConfiguration(
name="foo", server_address="bar", username="baz", password="ohea",
providers={
"1": ProviderConfiguration(
id="1",
name="foo",
ground_truth_adapter_type=SubsonicAdapter,
ground_truth_adapter_config=subsonic_config_store,
caching_adapter_type=FilesystemAdapter,
caching_adapter_config=ConfigurationStore(),
)
],
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

@@ -8,6 +8,7 @@ from typing import Any, Generator, List, Tuple
import pytest
from dateutil.tz import tzutc
from sublime.adapters import ConfigurationStore
from sublime.adapters.subsonic import (
api_objects as SubsonicAPI,
SubsonicAdapter,
@@ -18,14 +19,15 @@ MOCK_DATA_FILES = Path(__file__).parent.joinpath("mock_data")
@pytest.fixture
def adapter(tmp_path: Path):
adapter = SubsonicAdapter(
{
"server_address": "http://subsonic.example.com",
"username": "test",
"password": "testpass",
},
tmp_path,
ConfigurationStore.MOCK = True
config = ConfigurationStore(
server_address="https://subsonic.example.com",
username="test",
verify_cert=True,
)
config.set_secret("password", "testpass")
adapter = SubsonicAdapter(config, tmp_path)
adapter._is_mock = True
yield adapter
adapter.shutdown()
@@ -74,6 +76,12 @@ def camel_to_snake(name: str) -> str:
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
def test_config_form():
# Just make sure that the functions work. That's half of the battle.
config_store = ConfigurationStore()
SubsonicAdapter.get_configuration_form(config_store)
def test_request_making_methods(adapter: SubsonicAdapter):
expected = {
"u": "test",
@@ -84,7 +92,7 @@ def test_request_making_methods(adapter: SubsonicAdapter):
}
assert sorted(expected.items()) == sorted(adapter._get_params().items())
assert adapter._make_url("foo") == "http://subsonic.example.com/rest/foo.view"
assert adapter._make_url("foo") == "https://subsonic.example.com/rest/foo.view"
def test_ping_status(adapter: SubsonicAdapter):

View File

@@ -1,65 +1,87 @@
import os
from dataclasses import asdict
from pathlib import Path
import yaml
import pytest
from sublime.config import AppConfiguration, ReplayGainType, ServerConfiguration
from sublime.adapters import ConfigurationStore
from sublime.adapters.filesystem import FilesystemAdapter
from sublime.adapters.subsonic import SubsonicAdapter
from sublime.config import AppConfiguration, ProviderConfiguration, ReplayGainType
@pytest.fixture
def config_filename(tmp_path: Path):
yield tmp_path.joinpath("config.json")
def test_config_default_cache_location():
config = AppConfiguration()
assert config.cache_location == os.path.expanduser("~/.local/share/sublime-music")
assert config.cache_location == Path("~/.local/share/sublime-music").expanduser()
def test_server_property():
config = AppConfiguration()
server = ServerConfiguration(name="foo", server_address="bar", username="baz")
config.servers.append(server)
assert config.server is None
config.current_server_index = 0
assert asdict(config.server) == asdict(server)
provider = ProviderConfiguration(
id="1",
name="foo",
ground_truth_adapter_type=SubsonicAdapter,
ground_truth_adapter_config=ConfigurationStore(),
)
config.providers["1"] = provider
assert config.provider is None
config.current_provider_id = "1"
assert config.provider == provider
expected_state_file_location = Path("~/.local/share").expanduser()
expected_state_file_location = expected_state_file_location.joinpath(
"sublime-music", "6df23dc03f9b54cc38a0fc1483df6e21", "state.pickle",
"sublime-music", "1", "state.pickle",
)
assert config.state_file_location == expected_state_file_location
assert config._state_file_location == expected_state_file_location
def test_yaml_load_unload():
config = AppConfiguration()
server = ServerConfiguration(name="foo", server_address="bar", username="baz")
config.servers.append(server)
config.current_server_index = 0
yamlified = yaml.dump(asdict(config))
unyamlified = yaml.load(yamlified, Loader=yaml.CLoader)
deserialized = AppConfiguration(**unyamlified)
return
# TODO (#197) reinstate these tests with the new config system.
# Make sure that the config and each of the servers gets loaded in properly
# into the dataclass objects.
assert asdict(config) == asdict(deserialized)
assert type(deserialized.replay_gain) == ReplayGainType
for i, server in enumerate(deserialized.servers):
assert asdict(config.servers[i]) == asdict(server)
def test_config_migrate():
config = AppConfiguration(always_stream=True)
server = ServerConfiguration(
name="Test", server_address="https://test.host", username="test"
def test_json_load_unload(config_filename: Path):
ConfigurationStore.MOCK = True
subsonic_config_store = ConfigurationStore(username="test")
subsonic_config_store.set_secret("password", "testpass")
original_config = AppConfiguration(
providers={
"1": ProviderConfiguration(
id="1",
name="foo",
ground_truth_adapter_type=SubsonicAdapter,
ground_truth_adapter_config=subsonic_config_store,
caching_adapter_type=FilesystemAdapter,
caching_adapter_config=ConfigurationStore(),
)
},
current_provider_id="1",
filename=config_filename,
)
original_config.save()
loaded_config = AppConfiguration.load_from_file(config_filename)
assert original_config.version == loaded_config.version
assert original_config.providers == loaded_config.providers
assert original_config.provider == loaded_config.provider
def test_config_migrate(config_filename: Path):
config = AppConfiguration(
providers={
"1": ProviderConfiguration(
id="1",
name="foo",
ground_truth_adapter_type=SubsonicAdapter,
ground_truth_adapter_config=ConfigurationStore(),
)
},
current_provider_id="1",
filename=config_filename,
)
config.servers.append(server)
config.migrate()
assert config.version == 4
assert config.allow_song_downloads is False
for server in config.servers:
server.version == 0
assert config.version == 5
def test_replay_gain_enum():