diff --git a/docs/adapter-api.rst b/docs/adapter-api.rst
index 64a8bfc..1017c3c 100644
--- a/docs/adapter-api.rst
+++ b/docs/adapter-api.rst
@@ -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::
diff --git a/docs/conf.py b/docs/conf.py
index 9c506ca..06ee5b8 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -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.
diff --git a/flatpak/flatpak-requirements.txt b/flatpak/flatpak-requirements.txt
index e885322..a030020 100644
--- a/flatpak/flatpak-requirements.txt
+++ b/flatpak/flatpak-requirements.txt
@@ -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
diff --git a/setup.py b/setup.py
index c59036d..81fad24 100644
--- a/setup.py
+++ b/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 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=[
diff --git a/sublime/__main__.py b/sublime/__main__.py
index 5276d2c..1e1174d 100644
--- a/sublime/__main__.py
+++ b/sublime/__main__.py
@@ -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))
diff --git a/sublime/adapters/__init__.py b/sublime/adapters/__init__.py
index f2e77f8..1f0d5cb 100644
--- a/sublime/adapters/__init__.py
+++ b/sublime/adapters/__init__.py
@@ -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",
)
diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py
index 5f537bf..b6252fb 100644
--- a/sublime/adapters/adapter_base.py
+++ b/sublime/adapters/adapter_base.py
@@ -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.
diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py
index 0efeaab..d2dab0b 100644
--- a/sublime/adapters/filesystem/adapter.py
+++ b/sublime/adapters/filesystem/adapter.py
@@ -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
diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py
index 35ae331..7288ee6 100644
--- a/sublime/adapters/manager.py
+++ b/sublime/adapters/manager.py
@@ -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"),
diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py
index ed78cb6..1975c4e 100644
--- a/sublime/adapters/subsonic/adapter.py
+++ b/sublime/adapters/subsonic/adapter.py
@@ -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
diff --git a/sublime/ui/icons/server-subsonic-connected-symbolic.svg b/sublime/adapters/subsonic/icons/subsonic-connected-symbolic.svg
similarity index 100%
rename from sublime/ui/icons/server-subsonic-connected-symbolic.svg
rename to sublime/adapters/subsonic/icons/subsonic-connected-symbolic.svg
diff --git a/sublime/ui/icons/server-subsonic-error-symbolic.svg b/sublime/adapters/subsonic/icons/subsonic-error-symbolic.svg
similarity index 100%
rename from sublime/ui/icons/server-subsonic-error-symbolic.svg
rename to sublime/adapters/subsonic/icons/subsonic-error-symbolic.svg
diff --git a/sublime/ui/icons/server-subsonic-offline-symbolic.svg b/sublime/adapters/subsonic/icons/subsonic-offline-symbolic.svg
similarity index 100%
rename from sublime/ui/icons/server-subsonic-offline-symbolic.svg
rename to sublime/adapters/subsonic/icons/subsonic-offline-symbolic.svg
diff --git a/sublime/adapters/subsonic/icons/subsonic-symbolic.svg b/sublime/adapters/subsonic/icons/subsonic-symbolic.svg
new file mode 100644
index 0000000..016be07
--- /dev/null
+++ b/sublime/adapters/subsonic/icons/subsonic-symbolic.svg
@@ -0,0 +1,4 @@
+
diff --git a/sublime/app.py b/sublime/app.py
index dca49af..44152e2 100644
--- a/sublime/app.py
+++ b/sublime/app.py
@@ -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,
diff --git a/sublime/config.py b/sublime/config.py
index bc93eb0..71cb07f 100644
--- a/sublime/config.py
+++ b/sublime/config.py
@@ -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)
diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py
index 05107ca..ed68e7e 100644
--- a/sublime/ui/albums.py
+++ b/sublime/ui/albums.py
@@ -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,
diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css
index 666b701..4c91109 100644
--- a/sublime/ui/app_styles.css
+++ b/sublime/ui/app_styles.css
@@ -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;
diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py
index f618a4a..3c347a2 100644
--- a/sublime/ui/browse.py
+++ b/sublime/ui/browse.py
@@ -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
diff --git a/sublime/ui/configure_provider.py b/sublime/ui/configure_provider.py
new file mode 100644
index 0000000..f2a785f
--- /dev/null
+++ b/sublime/ui/configure_provider.py
@@ -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"{ui_info.name}\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)
diff --git a/sublime/ui/configure_servers.py b/sublime/ui/configure_servers.py
index 382dc94..b8ab108 100644
--- a/sublime/ui/configure_servers.py
+++ b/sublime/ui/configure_servers.py
@@ -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
diff --git a/sublime/ui/main.py b/sublime/ui/main.py
index acb29ee..5a4f6e3 100644
--- a/sublime/ui/main.py
+++ b/sublime/ui/main.py
@@ -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"{app_config.server.name}")
+ if app_config.provider:
+ self.connected_to_label.set_markup(f"{app_config.provider.name}")
else:
self.connected_to_label.set_markup("No Music Source Selected")
diff --git a/tests/config_test.py b/tests/config_test.py
index 22e9b5b..a354035 100644
--- a/tests/config_test.py
+++ b/tests/config_test.py
@@ -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():