Started work on making adapter-defined config

This commit is contained in:
Sumner Evans
2020-06-06 00:27:49 -06:00
parent 1426268925
commit cb551d865b
23 changed files with 516 additions and 221 deletions

View File

@@ -49,6 +49,8 @@ class should be enough to implement the entire adapter.
After you've created the class, you will want to implement the following
functions and properties first:
* ``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.
@@ -70,10 +72,14 @@ functions and properties first:
.. TODO: these are totally wrong
* ``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.
* ``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.
If you don't want to implement all of the GTK logic yourself, and just want a
simple form, then you can use the
:class:`sublime.adapters.ConfigureServerForm` class to generate a form in a
declarative manner.
.. note::

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,10 @@ from .adapter_base import (
CacheMissError,
CachingAdapter,
ConfigParamDescriptor,
ConfigurationStore,
ConfigureServerForm,
SongCacheStatus,
UIInfo,
)
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
@@ -15,8 +18,11 @@ __all__ = (
"CacheMissError",
"CachingAdapter",
"ConfigParamDescriptor",
"ConfigurationStore",
"ConfigureServerForm",
"DownloadProgress",
"Result",
"SearchResult",
"SongCacheStatus",
"UIInfo",
)

View File

@@ -6,6 +6,7 @@ from enum import Enum
from pathlib import Path
from typing import (
Any,
Callable,
Dict,
Iterable,
Optional,
@@ -16,6 +17,9 @@ from typing import (
Union,
)
from dataclasses_json import dataclass_json
from gi.repository import Gtk
from .api_objects import (
Album,
Artist,
@@ -152,6 +156,49 @@ class CacheMissError(Exception):
super().__init__(*args)
@dataclass_json
@dataclass
class ConfigurationStore:
"""
This defines an abstract store for all configuration parameters for a given Adapter.
"""
def __init__(self, id: str):
self._store: Dict[str, Any] = {}
def get(self, key: str, default: Any = None) -> Any:
"""
Get the configuration value in the store with the given key. If the key doesn't
exist in the store, return the default.
"""
return self._store.get(key, default)
def set(self, key: str, value: Any):
"""
Set the value of the given key in the store.
"""
self._store[key] = value
def get_secret(self, key: str, default: Any = None) -> 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.
"""
# TODO make secret storage less stupid.
return self._store.get(key, default)
def set_secret(self, key: str, default: 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.
"""
return self._store.get(key, default)
@dataclass
class ConfigParamDescriptor:
"""
@@ -167,6 +214,14 @@ class ConfigParamDescriptor:
* The literal string ``"password"``: corresponds to a password entry field in the
UI.
* The literal string ``"option"``: corresponds to dropdown in the UI.
* The literal string ``"directory"``: corresponds to a directory picker in the UI.
* The literal sting ``"fold"``: corresponds to a expander where following components
are under an expander component. The title of the expander is the description of
this class. All of the components until the next ``"fold"``, and ``"endfold"``
component, or the end of the configuration paramter dictionary are included under
the expander component.
* The literal string ``endfold``: end a ``"fold"``. The description must be the same
as the corresponding start form.
The :class:`hidden_behind` is an optional string representing the name of the
expander that the component should be displayed underneath. For example, one common
@@ -195,6 +250,56 @@ class ConfigParamDescriptor:
options: Optional[Iterable[str]] = None
class ConfigureServerForm(Gtk.Box):
def __init__(
self,
config_store: ConfigurationStore,
config_parameters: Dict[str, ConfigParamDescriptor],
verify_configuration: Callable[[], Dict[str, Optional[str]]],
):
"""
Inititialize a :class:`ConfigureServerForm` with the given configuration
parameters.
:param config_store: The :class:`ConfigurationStore` to use to store
configuration values for this adapter.
:param config_parameters: An dictionary where the keys are the name of the
configuration paramter and the values are the :class:`ConfigParamDescriptor`
object corresponding to that configuration parameter. The order of the keys
in the dictionary correspond to the order that the configuration parameters
will be shown in the UI.
:param verify_configuration: A function that verifies whether or not the
current state of the ``config_store`` is valid. The output should be a
dictionary containing verification errors. The keys of the returned
dictionary should be the same as the keys passed in via the
``config_parameters`` parameter. The values should be strings describing
why the corresponding value in the ``config_store`` is invalid.
"""
Gtk.Box.__init__(self)
content_grid = Gtk.Grid(
column_spacing=10, row_spacing=5, margin_left=10, margin_right=10,
)
for key, cpd in config_parameters.items():
print(key, cpd)
self.add(content_grid)
@dataclass
class UIInfo:
name: str
description: str
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):
"""
Defines the interface for a Sublime Music Adapter.
@@ -205,45 +310,38 @@ 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``.
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.
"""
: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.
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 +389,14 @@ class Adapter(abc.ABC):
"""
return True
@property
@staticmethod
def can_be_ground_truth() -> bool:
"""
Whether or not this adapter can be used as a ground truth adapter.
"""
return True
# Network Properties
# These properties determine whether or not the adapter requires connection over a
# network and whether the underlying server can be pinged.

View File

@@ -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="directory", description="Music Directory"
)
},
verify_config_store,
)
@staticmethod
def migrate_configuration(config_store: ConfigurationStore):
pass
def __init__(
self, config: dict, data_directory: Path, is_cache: bool = False,
@@ -78,6 +96,7 @@ class FilesystemAdapter(CachingAdapter):
# Usage and Availability Properties
# ==================================================================================
can_be_cached = False # Can't be cached (there's no need).
can_be_ground_truth = False # TODO (#188)
is_networked = False # Doesn't access the network.
# TODO (#200) make these dependent on cache state. Need to do this kinda efficiently

View File

@@ -24,7 +24,6 @@ from typing import (
Sequence,
Set,
Tuple,
Type,
TypeVar,
Union,
)
@@ -50,6 +49,7 @@ from .api_objects import (
)
from .filesystem import FilesystemAdapter
from .subsonic import SubsonicAdapter
from sublime.config import ProviderConfiguration
REQUEST_DELAY: Optional[Tuple[float, float]] = None
if delay_str := os.environ.get("REQUEST_DELAY"):
@@ -228,12 +228,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
@@ -289,26 +283,25 @@ class AdapterManager:
# 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)
key: getattr(config.provider, key)
for key in caching_adapter_type.get_config_parameters()
},
source_data_dir.joinpath("c"),

View File

@@ -23,9 +23,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
@@ -63,15 +72,29 @@ 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),
"username": ConfigParamDescriptor(str, "Username"),
"password": ConfigParamDescriptor("password", "Password"),
"-": ConfigParamDescriptor("fold", "Advanced"),
"disable_cert_verify": ConfigParamDescriptor(
bool, "Verify certificate", True
),
"sync_enabled": ConfigParamDescriptor(
bool, "Synchronize play queue state", True
),
}
if networkmanager_imported:
configs.update(
{
@@ -83,17 +106,21 @@ class SubsonicAdapter(Adapter):
),
}
)
return configs
def verify_configuration() -> Dict[str, Optional[str]]:
errors: Dict[str, Optional[str]] = {}
# TODO (#197): verify the URL and ping it.
# Maybe have a special key like __ping_future__ or something along those
# lines to add a function that allows the UI to check whether or not
# connecting to the server will work?
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]] = {}
# 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 migrate_configuration(config_store: ConfigurationStore):
pass
def __init__(self, config: dict, data_directory: Path):
self.data_directory = data_directory

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24">
<path d="M6.93 2.13v.04h-.08l-1.39.26c-.15.04-.22.2-.22.38l.22 4.39c0 .22.16.37.34.34l1.69-.57c.11-.03.19-.18.15-.37l-.49-4.17c0-.18-.11-.3-.22-.3zm4.65 4.54c-1.35 0-2.25 0-3.49.68l-2.32.82c-.15.04-.23.19-.27.34a3.6 3.6 0 00-.15 2.1 24 24 0 00-1.46.75c-.45-.71-1.87-.64-1.87.37-.08.5-.42 1.2.22 1.47.3.37-.53.75-.68 1.16-.15.37-.22.75-.7.68-.5.18-.72.63-.34 1.04.37.3 1 0 .9.68.07.49.4.97.6 1.35-.45.37-.38.98-.72 1.43-.26.56.15 1.27.83 1.12a4.73 4.73 0 002.02-.75c3 .82 6.03 1.75 9.13 1.92a6.1 6.1 0 01-.86-4.97l-.02-.21c0-1.02.19-1.62.83-2.25.26-.27.64-.43 1.04-.52a6.1 6.1 0 013.97-1.5 6.01 6.01 0 015.43 3.43 5.5 5.5 0 00-.36-1.94c-.52-1.42-1.87-2.51-3.15-3.26-.75-.38-1.72-.41-2.54-.53l-.27-1.27c.11-.68-.64-.6-1.12-.75l-2.03-.34c-.48-.67-1.87-1.05-2.62-1.05zM5.9 13.05c.38.03.57.67.57 1.83 0 1.1-.23 1.77-.75 2.25-.38.34-.6.3-.83-.18-.45-1.05-.22-2.89.41-3.57.23-.22.42-.37.6-.33zm3.72.22c.56 0 .86.6.93 1.77.04 1.3-.26 2.2-.97 2.73-.34.23-.75.34-.97.23-.3-.12-.64-.98-.68-1.7-.11-1.38.41-2.58 1.27-2.92.13-.06.27-.1.42-.1z" fill="#e4e4e4"/>
<path d="M18.2 13.17A5.23 5.23 0 0013 18.4a5.23 5.23 0 005.24 5.24 5.23 5.23 0 005.23-5.24 5.23 5.23 0 00-5.27-5.23z" fill="#c70e0e" class="success"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -39,10 +39,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 +74,10 @@ 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-provider", self.on_add_new_provider)
add_action("edit-current-provider", self.on_edit_current_provider)
add_action("switch-provider", self.on_switch_provider)
add_action("remove-provider", self.on_remove_provider)
# Add actions for player controls
add_action("play-pause", self.on_play_pause)
@@ -115,8 +118,13 @@ class SublimeMusicApp(Gtk.Application):
return
# Configure Icons
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_dir = Path(__file__).parent.joinpath("ui", "icons")
Gtk.IconTheme.get_default().append_search_path(str(icon_dir))
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 +147,23 @@ 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.app_config._current_provider_id = 0
else:
self.show_configure_servers_dialog()
# If they didn't add one with the dialog, close the window.
if self.app_config.provider is None:
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 +288,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,8 +541,32 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.current_notification = None
self.update_window()
def on_configure_servers(self, *args):
self.show_configure_servers_dialog()
# TODO
def on_server_list_changed(self, action: Any, servers: GLib.Variant):
assert 0
self.app_config.providers = servers
self.app_config.save()
def on_connected_server_changed(self, action: Any, current_server_id: str):
assert 0
if self.app_config.provider:
self.app_config.save()
self.app_config.current_server_id = current_server_id
self.app_config.save()
self.reset_state()
def on_add_new_provider(self, _, provider: ProviderConfiguration):
pass
def on_edit_current_provider(self, _):
pass
def on_switch_provider(self, _, provider_id: str):
pass
def on_remove_provider(self, _, provider_id: str):
pass
def on_window_go_to(self, win: Any, action: str, value: str):
{
@@ -679,20 +717,6 @@ 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()
@@ -876,7 +900,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 +915,14 @@ 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()
print(result)
print(dialog)
dialog.destroy()
def update_window(self, force: bool = False):
@@ -1267,7 +1293,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 +1304,7 @@ class SublimeMusicApp(Gtk.Application):
position = self.app_config.state.song_progress
self.last_play_queue_update = position or timedelta(0)
if self.app_config.server.sync_enabled and self.app_config.state.current_song:
if AdapterManager.can_save_play_queue() and self.app_config.state.current_song:
AdapterManager.save_play_queue(
song_ids=self.app_config.state.play_queue,
current_song_index=self.app_config.state.current_song_index,

View File

@@ -1,17 +1,35 @@
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 Dict, Optional, Type
import yaml
import dataclasses_json
from dataclasses_json import dataclass_json, DataClassJsonMixin
from sublime.adapters import ConfigurationStore
from sublime.ui.state import UIState
# JSON decoder and encoder translations
decoder_functions = {
Path: (lambda p: Path(p) if p else None),
}
encoder_functions = {
Path: (lambda p: str(p.resolve()) if p else None),
}
for type_, translation_function in decoder_functions.items():
dataclasses_json.cfg.global_config.decoders[type_] = translation_function
dataclasses_json.cfg.global_config.decoders[Optional[type_]] = translation_function
for type_, translation_function in encoder_functions.items():
dataclasses_json.cfg.global_config.encoders[type_] = translation_function
dataclasses_json.cfg.global_config.encoders[Optional[type_]] = translation_function
class ReplayGainType(Enum):
NO = 0
TRACK = 1
@@ -30,55 +48,33 @@ class ReplayGainType(Enum):
}[replay_gain_type.lower()]
@dataclass_json
@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
_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).
>>> 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
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)
@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)
current_provider_id: Optional[str] = None
# Global Settings
song_play_notification: bool = True
@@ -96,19 +92,16 @@ 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
config.save()
return config
def __post_init__(self):
@@ -116,82 +109,67 @@ 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 = str(path)
self._state = None
self._current_server_hash = None
self._current_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._current_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._current_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.warning(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)
with open(self.filename, "w+") as f:
f.write(yaml.dump(asdict(self)))
f.write(self.to_json(indent=2, sort_keys=True))
# Save the state for the current server.
if state_file_location := self.state_file_location:
state_file_location.parent.mkdir(parents=True, exist_ok=True)
with open(state_file_location, "wb+") as f:
# Save the state for the current provider.
if state_filename := self._state_file_location:
state_filename.parent.mkdir(parents=True, exist_ok=True)
with open(state_filename, "wb+") as f:
pickle.dump(self.state, f)

View File

@@ -519,7 +519,7 @@ class AlbumsGrid(Gtk.Overlay):
page: int = 0
num_pages: Optional[int] = None
next_page_fn = None
server_hash: Optional[str] = None
server_id: Optional[str] = None
def update_params(self, query: AlbumSearchQuery, offline_mode: bool) -> int:
# If there's a diff, increase the ratchet.
@@ -620,10 +620,10 @@ 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:
assert app_config.provider
if self.server_id != app_config.provider.id:
self.order_ratchet += 1
self.server_hash = new_hash
self.server_id = app_config.provider.id
self.update_grid(
order_token,

View File

@@ -64,6 +64,11 @@
min-width: 230px;
}
/* ********** Configure Provider Dialog ********** */
#ground-truth-adapter-options-list {
margin: 0 40px;
}
/* ********** Playlist ********** */
#playlist-list-listbox row {
margin: 0;

View File

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

View File

@@ -0,0 +1,107 @@
from typing import Any, Optional, Type
from gi.repository import Gio, GLib, GObject, Gtk, Pango
from sublime.adapters import Adapter, AdapterManager, UIInfo
from sublime.config import 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 ConfigureProviderDialog(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: Optional[ProviderConfiguration]):
title = "Add New Music Source" if not config else "Edit {config.name}"
Gtk.Dialog.__init__(
self,
title=title,
transient_for=parent,
flags=Gtk.DialogFlags.MODAL,
add_buttons=(),
)
self.set_default_size(400, 500)
# HEADER
header = Gtk.HeaderBar()
header.props.title = title
cancel_button = Gtk.Button(label="Cancel")
cancel_button.connect("clicked", lambda *a: self.close())
header.pack_start(cancel_button)
next_button = Gtk.Button(label="Next")
next_button.connect("clicked", self._on_next_clicked)
header.pack_end(next_button)
self.set_titlebar(header)
content_area = self.get_content_area()
# ADAPTER TYPE OPTIONS
self.adapter_type_store = Gio.ListStore()
self.adapter_options_list = Gtk.ListBox(
name="ground-truth-adapter-options-list"
)
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
)
# TODO
available_ground_truth_adapters = 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))
content_area.pack_start(self.adapter_options_list, True, True, 10)
self.show_all()
def _on_next_clicked(self, _):
index = self.adapter_options_list.get_selected_row().get_index()
adapter_type = self.adapter_type_store[index].adapter_type
print(adapter_type)

View File

@@ -3,7 +3,7 @@ from typing import Any
from gi.repository import GObject, Gtk
from sublime.config import AppConfiguration, ServerConfiguration
from sublime.config import AppConfiguration
from sublime.ui.common import EditFormDialog, IconButton

View File

@@ -137,8 +137,8 @@ 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>")
else:
self.connected_to_label.set_markup("<i>No Music Source Selected</i>")

View File

@@ -24,7 +24,7 @@ def test_server_property():
expected_state_file_location = expected_state_file_location.joinpath(
"sublime-music", "6df23dc03f9b54cc38a0fc1483df6e21", "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():