Merge branch '197-adapter-defined-config'
@@ -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::
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
|
22
setup.py
@@ -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=[
|
||||
|
@@ -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))
|
||||
|
@@ -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",
|
||||
)
|
||||
|
@@ -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.
|
||||
|
364
sublime/adapters/configure_server_form.py
Normal 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())
|
||||
)
|
@@ -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
|
||||
|
3
sublime/adapters/icons/config-error-symbolic.svg
Normal 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 |
3
sublime/adapters/icons/config-ok-symbolic.svg
Normal 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 |
@@ -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,
|
||||
)
|
||||
|
@@ -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,
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
1
sublime/adapters/subsonic/icons/subsonic-symbolic.svg
Normal 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 |
136
sublime/app.py
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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;
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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))
|
||||
|
228
sublime/ui/configure_provider.py
Normal 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()
|
@@ -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))
|
@@ -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()
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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):
|
||||
|
@@ -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():
|
||||
|