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():