diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index db8cd00..088cdeb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,6 +11,10 @@ variables:
LC_ALL: "C.UTF-8"
LANG: "C.UTF-8"
+cache:
+ paths:
+ - .venv/
+
image: registry.gitlab.com/sumner/sublime-music/python-build:latest
lint:
@@ -29,6 +33,9 @@ test:
before_script:
- ./cicd/install-project-deps.sh
- ./cicd/start-dbus.sh
+ - apt install xvfb
+ - Xvfb :119 -screen 0 1024x768x16 &
+ - export DISPLAY=:119
script:
- pipenv run ./cicd/pytest.sh
artifacts:
@@ -74,6 +81,7 @@ pages:
deploy_pypi:
image: python:3.8-alpine
stage: deploy
+ cache: {}
only:
variables:
# Only do a deploy if it's a version tag.
@@ -91,6 +99,7 @@ deploy_pypi:
verify_deploy:
stage: verify
+ cache: {}
only:
variables:
# Only verify the deploy if it's a version tag.
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 8494339..4605431 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,21 +1,73 @@
-v0.9.3
-======
+v0.10.0
+=======
-* **Features**
+.. warning::
- * The Albums tab is now paginated with configurable page sizes.
- * You can sort the Albums tab ascending or descending.
- * Opening an closing an album on the Albums tab now has a nice animation.
- * The amount of the song that is cached is now shown while streaming a song.
- * The notification for resuming a play queue is now a non-modal
- notification that pops up right above the player controls.
+ This version is not compatible with any previous versions. If you have run a
+ previous version of Sublime Music, please delete your cache (likely in
+ ``~/.local/share/sublime-music``) and your existing configuration (likely in
+ ``~/.config/sublime-music``) and re-run Sublime Music to restart the
+ configuration process.
-* This release has a ton of under-the-hood changes to make things more robust
- and performant.
+Features
+--------
- * The cache is now stored in a SQLite database.
- * The cache is no longer reliant on Subsonic which will enable more backends
- in the future.
+**Albums Tab Improvements**
+
+* The Albums tab is now paginated with configurable page sizes.
+* You can sort the Albums tab ascending or descending.
+* Opening an closing an album on the Albums tab now has a nice animation.
+
+**Player Controls**
+
+* The amount of the song that is cached is now shown while streaming a song.
+* The notification for resuming a play queue is now a non-modal notification
+ that pops up right above the player controls.
+
+**New Icons**
+
+* The Devices button now uses the Chromecast logo.
+* Custom icons for "Add to play queue", and "Play next" buttons. Thanks to
+ @samsartor for contributing the SVGs!
+* A new icon for indicating the connection state to the Subsonic server.
+ Contributed by @samsartor.
+* A new icon for that data wasn't able to be loaded due to being offline.
+ Contributed by @samsartor.
+
+**Application Menus**
+
+* Settings are now in the popup under the gear icon rather than in a separate
+ popup window.
+* You can now clear the cache via an option in the Downloads popup. There are
+ options for removing the entire cache and removing just the song file cache.
+
+.. * The music provider configuration has gotten a major revamp.
+.. * The Downloads popup shows the songs that are currently being downloaded.
+.. *
+
+**Offline Mode**
+
+* You can enable *Offline Mode* from the server menu.
+* Features that require network access are disabled in offline mode.
+* You can still browse anything that is already cached offline.
+
+**Other Features**
+
+.. * A man page has been added. Contributed by @baldurmen.
+
+Under The Hood
+--------------
+
+This release has a ton of under-the-hood changes to make things more robust
+and performant.
+
+* The cache is now stored in a SQLite database.
+* The cache no longer gets corrupted when Sublime Music fails to write to disk.
+* A generic `Adapter API`_ has been created which means that Sublime Music is no
+ longer reliant on Subsonic. This means that in the future, more backends can
+ be added.
+
+.. _Adapter API: https://sumner.gitlab.io/sublime-music/adapter-api.html
v0.9.2
======
diff --git a/Pipfile b/Pipfile
index ef15662..7e21f05 100644
--- a/Pipfile
+++ b/Pipfile
@@ -27,7 +27,6 @@ termcolor = "*"
[packages]
sublime-music = {editable = true,extras = ["keyring"],path = "."}
-dataclasses-json = {editable = true,git = "https://github.com/lidatong/dataclasses-json",ref = "master"}
[requires]
python_version = "3.8"
diff --git a/Pipfile.lock b/Pipfile.lock
index cf9d6df..40b733d 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "c72a092370d49b350cf6565988c36a58e5e80cf0f322a07e0f0eaa6cffe2f39f"
+ "sha256": "a04cebbd47f79d5d7fa2266238fde4b5530a0271d5bb2a514e579a1eed3632f6"
},
"pipfile-spec": 6,
"requires": {
@@ -102,9 +102,11 @@
"version": "==2.9.2"
},
"dataclasses-json": {
- "editable": true,
- "git": "https://github.com/lidatong/dataclasses-json",
- "ref": "b8b60cdaa2c3ccc8d3bcbce67e911b705c3b0b10"
+ "hashes": [
+ "sha256:175a30bdbd10d85022bb8684c7e0749217547d842692b2617f982ce197ab6121",
+ "sha256:6c022dc5598162972253c197a3af16d08c0f9eb30630da383ac165a3903a4d11"
+ ],
+ "version": "==0.4.3"
},
"deepdiff": {
"hashes": [
@@ -183,24 +185,24 @@
},
"protobuf": {
"hashes": [
- "sha256:00c2c276aca3af220d422e6a8625b1f5399c821c9b6f1c83e8a535aa8f48cc6c",
- "sha256:0d69d76b00d0eb5124cb33a34a793383a5bbbf9ac3e633207c09988717c5da85",
- "sha256:1c55277377dd35e508e9d86c67a545f6d8d242d792af487678eeb75c07974ee2",
- "sha256:35bc1b96241b8ea66dbf386547ef2e042d73dcc0bf4b63566e3ef68722bb24d1",
- "sha256:47a541ac44f2dcc8d49b615bcf3ed7ba4f33af9791118cecc3d17815fab652d9",
- "sha256:61364bcd2d85277ab6155bb7c5267e6a64786a919f1a991e29eb536aa5330a3d",
- "sha256:7aaa820d629f8a196763dd5ba21fd272fa038f775a845a52e21fa67862abcd35",
- "sha256:9593a6cdfc491f2caf62adb1c03170e9e8748d0a69faa2b3970e39a92fbd05a2",
- "sha256:95f035bbafec7dbaa0f1c72eda8108b763c1671fcb6e577e93da2d52eb47fbcf",
- "sha256:9d6a517ce33cbdc64b52a17c56ce17b0b20679c945ed7420e7c6bc6686ff0494",
- "sha256:a7532d971e4ab2019a9f6aa224b209756b6b9e702940ca85a4b1ed1d03f45396",
- "sha256:b4e8ecb1eb3d011f0ccc13f8bb0a2d481aa05b733e6e22e9d46a3f61dbbef0de",
- "sha256:bb1aced9dcebc46f0b320f24222cc8ffdfd2e47d2bafd4d2e5913cc6f7e3fc98",
- "sha256:ccce142ebcfbc35643a5012cf398497eb18e8d021333cced4d5401f034a8cef5",
- "sha256:d538eecc0b80accfb73c8167f39aaa167a5a50f31b1295244578c8eff8e9d602",
- "sha256:eab18765eb5c7bad1b2de7ae3774192b46e1873011682e36bcd70ccf75f2748a"
+ "sha256:04d0b2bd99050d09393875a5a25fd12337b17f3ac2e29c0c1b8e65b277cbfe72",
+ "sha256:05288e44638e91498f13127a3699a6528dec6f9d3084d60959d721bfb9ea5b98",
+ "sha256:175d85370947f89e33b3da93f4ccdda3f326bebe3e599df5915ceb7f804cd9df",
+ "sha256:440a8c77531b3652f24999b249256ed01fd44c498ab0973843066681bd276685",
+ "sha256:49fb6fab19cd3f30fa0e976eeedcbf2558e9061e5fa65b4fe51ded1f4002e04d",
+ "sha256:4c7cae1f56056a4a2a2e3b00b26ab8550eae738bd9548f4ea0c2fcb88ed76ae5",
+ "sha256:519abfacbb421c3591d26e8daf7a4957763428db7267f7207e3693e29f6978db",
+ "sha256:60f32af25620abc4d7928d8197f9f25d49d558c5959aa1e08c686f974ac0b71a",
+ "sha256:613ac49f6db266fba243daf60fb32af107cfe3678e5c003bb40a381b6786389d",
+ "sha256:954bb14816edd24e746ba1a6b2d48c43576393bbde2fb8e1e3bd6d4504c7feac",
+ "sha256:9b1462c033a2cee7f4e8eb396905c69de2c532c3b835ff8f71f8e5fb77c38023",
+ "sha256:c0767f4d93ce4288475afe0571663c78870924f1f8881efd5406c10f070c75e4",
+ "sha256:c45f5980ce32879391144b5766120fd7b8803129f127ce36bd060dd38824801f",
+ "sha256:eeb7502f59e889a88bcb59f299493e215d1864f3d75335ea04a413004eb4fe24",
+ "sha256:fdb1742f883ee4662e39fcc5916f2725fec36a5191a52123fec60f8c53b70495",
+ "sha256:fe554066c4962c2db0a1d4752655223eb948d2bfa0fb1c4a7f2c00ec07324f1c"
],
- "version": "==3.12.0"
+ "version": "==3.12.1"
},
"pycairo": {
"hashes": [
@@ -280,10 +282,10 @@
},
"six": {
"hashes": [
- "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
- "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
+ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
+ "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "version": "==1.14.0"
+ "version": "==1.15.0"
},
"stringcase": {
"hashes": [
@@ -323,10 +325,10 @@
},
"zeroconf": {
"hashes": [
- "sha256:51f25787c27cf7b903e6795e8763bccdaa71199f61b75af97f1bde036fa43b27",
- "sha256:a0cdd43ee8f00e7082f784c4226d2609070ad0b2aeb34b0154466950d2134de6"
+ "sha256:569c801e50891e0cc639c223e296e870dd9f6242a4f2b41d356666735b2a4264",
+ "sha256:bca127caa7d16217cbca78290dbee532b41d71e798b939548dc5a2c3a8f98e5e"
],
- "version": "==0.26.1"
+ "version": "==0.26.2"
}
},
"develop": {
@@ -433,11 +435,11 @@
},
"flake8": {
"hashes": [
- "sha256:6c1193b0c3f853ef763969238f6c81e9e63ace9d024518edc020d5f1d6d93195",
- "sha256:ea6623797bf9a52f4c9577d780da0bb17d65f870213f7b5bcc9fca82540c31d5"
+ "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634",
+ "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5"
],
"index": "pypi",
- "version": "==3.8.1"
+ "version": "==3.8.2"
},
"flake8-annotations": {
"hashes": [
@@ -690,11 +692,11 @@
},
"pytest-cov": {
"hashes": [
- "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b",
- "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"
+ "sha256:b6a814b8ed6247bd81ff47f038511b57fe1ce7f4cc25b9106f1a4b106f1d9322",
+ "sha256:c87dfd8465d865655a8213859f1b4749b43448b5fae465cb981e16d52a811424"
],
"index": "pypi",
- "version": "==2.8.1"
+ "version": "==2.9.0"
},
"pytz": {
"hashes": [
@@ -752,10 +754,10 @@
},
"six": {
"hashes": [
- "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
- "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
+ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
+ "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
],
- "version": "==1.14.0"
+ "version": "==1.15.0"
},
"snowballstemmer": {
"hashes": [
diff --git a/cicd/install-project-deps.sh b/cicd/install-project-deps.sh
index cb340cc..d73f5c3 100755
--- a/cicd/install-project-deps.sh
+++ b/cicd/install-project-deps.sh
@@ -3,4 +3,5 @@ export PYENV_ROOT="${HOME}/.pyenv"
export PATH="${PYENV_ROOT}/bin:$PATH"
eval "$(pyenv init -)"
+export PIPENV_VENV_IN_PROJECT=1
pipenv install --dev
diff --git a/cicd/python-build/Dockerfile b/cicd/python-build/Dockerfile
index c50725d..875517f 100644
--- a/cicd/python-build/Dockerfile
+++ b/cicd/python-build/Dockerfile
@@ -33,6 +33,7 @@ RUN apt update && \
python3-pip \
tk-dev \
wget \
+ xvfb \
xz-utils \
zlib1g-dev
diff --git a/docs/adapter-api.rst b/docs/adapter-api.rst
index b377a8a..64a8bfc 100644
--- a/docs/adapter-api.rst
+++ b/docs/adapter-api.rst
@@ -52,29 +52,29 @@ functions and properties first:
* ``__init__``: Used to initialize your adapter. See the
:class:`sublime.adapters.Adapter.__init__` documentation for the function
signature of the ``__init__`` function.
-* ``can_service_requests``: This property which will tell the UI whether or not
- your adapter can currently service requests. (See the
- :class:`sublime.adapters.Adapter.can_service_requests` documentation for
- examples of what you may want to check in this property.)
+* ``ping_status``: Assuming that your adapter requires connection to the
+ internet, this property needs to be implemented. (If your adapter doesn't
+ require connection to the internet, set
+ :class:`sublime.adapters.Adapter.is_networked` to ``False`` and ignore the
+ rest of this bullet point.)
+
+ This property will tell the UI whether or not the underlying server can be
+ pinged.
.. warning::
This function is called *a lot* (probably too much?) so it *must* return a
- value *instantly*. **Do not** perform a network request in this function.
- If your adapter depends on connection to the network use a periodic ping
- that updates a state variable that this function returns.
+ value *instantly*. **Do not** perform the actual network request in this
+ function. Instead, use a periodic ping that updates a state variable that
+ this function returns.
+
+.. 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.
-.. tip::
-
- While developing the adapter, setting ``can_service_requests`` to ``True``
- will indicate to the UI that your adapter is always ready to service
- requests. This can be a useful debugging tool.
-
.. note::
The :class:`sublime.adapters.Adapter` class is an `Abstract Base Class
@@ -118,21 +118,12 @@ to implement the actual adapter data retrieval functions.
For each data retrieval function there is a corresponding ``can_``-prefixed
property (CPP) which will be used by the UI to determine if the data retrieval
-function can be called at the given time. If the CPP is ``False``, the UI will
-never call the corresponding function (and if it does, it's a UI bug). The CPP
-can be dynamic, for example, if your adapter supports many API versions, some of
-the CPPs may depend on the API version.
-
-There is a special, global ``can_``-prefixed property which determines whether
-the adapter can currently service *any* requests. This should be used for checks
-such as making sure that the user is able to access the server. (However, this
-must be done in a non-blocking manner since this is called *a lot*.)
-
-.. code:: python
-
- @property
- def can_service_requests(self) -> bool:
- return self.cached_ping_result_is_ok()
+function can be called. If the CPP is ``False``, the UI will never call the
+corresponding function (and if it does, it's a UI bug). The CPP can be dynamic,
+for example, if your adapter supports many API versions, some of the CPPs may
+depend on the API version. However, CPPs should not be dependent on connection
+status (there are times where the user may want to force a connection retry,
+even if the most recent ping failed).
Here is an example of what a ``get_playlists`` interface for an external server
might look:
@@ -155,7 +146,7 @@ might look:
``True``.*
\* At the moment, this isn't really the case and the UI just kinda explodes
- if it doesn't have some of the functions available, but in the future guards
+ if it doesn't have some of the functions available, but in the future, guards
will be added around all of the function calls.
Usage Parameters
diff --git a/flatpak/flatpak-requirements.txt b/flatpak/flatpak-requirements.txt
index b3acdb8..6be8d72 100644
--- a/flatpak/flatpak-requirements.txt
+++ b/flatpak/flatpak-requirements.txt
@@ -1,5 +1,5 @@
bottle==0.12.18
-git+https://github.com/lidatong/dataclasses-json@b8b60cdaa2c3ccc8d3bcbce67e911b705c3b0b10#egg=dataclasses-json
+dataclasses-json==0.4.3
deepdiff==4.3.2
fuzzywuzzy==0.18.0
peewee==3.13.3
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..f4b3e4c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[tool.black]
+exclude = '''
+(
+ /flatpak/
+)
+'''
diff --git a/setup.cfg b/setup.cfg
index 7598b92..b2dd275 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[flake8]
extend-ignore = E203, E402, E722, W503, ANN002, ANN003, ANN101, ANN102, ANN204
-exclude = .git,__pycache__,build,dist,flatpak
+exclude = .git,__pycache__,build,dist,flatpak,.venv
max-line-length = 88
suppress-none-returning = True
suppress-dummy-args = True
diff --git a/setup.py b/setup.py
index 949f076..d49dfdb 100644
--- a/setup.py
+++ b/setup.py
@@ -53,7 +53,7 @@ setup(
},
install_requires=[
"bottle",
- "dataclasses-json @ git+https://github.com/lidatong/dataclasses-json@master#egg=dataclasses-json", # noqa: E501
+ "dataclasses-json",
"deepdiff",
"fuzzywuzzy",
'osxmmkeys ; sys_platform=="darwin"',
diff --git a/sublime-music.metainfo.xml b/sublime-music.metainfo.xml
index ce11ff9..4f63396 100644
--- a/sublime-music.metainfo.xml
+++ b/sublime-music.metainfo.xml
@@ -5,7 +5,7 @@
FSFAPGPL-3.0+Sublime Music
- Native Subsonic client for Linux
+ A native GTK music player with *sonic support
@@ -77,7 +77,6 @@
me_AT_sumnerevans.com
-
-
+
diff --git a/sublime/__init__.py b/sublime/__init__.py
index a2fecb4..61fb31c 100644
--- a/sublime/__init__.py
+++ b/sublime/__init__.py
@@ -1 +1 @@
-__version__ = "0.9.2"
+__version__ = "0.10.0"
diff --git a/sublime/adapters/adapter_base.py b/sublime/adapters/adapter_base.py
index 7e6374e..5f537bf 100644
--- a/sublime/adapters/adapter_base.py
+++ b/sublime/adapters/adapter_base.py
@@ -1,6 +1,5 @@
import abc
import hashlib
-import json
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
@@ -110,19 +109,24 @@ class AlbumSearchQuery:
"""
Returns a deterministic hash of the query as a string.
- >>> query = AlbumSearchQuery(
+ >>> AlbumSearchQuery(
... AlbumSearchQuery.Type.YEAR_RANGE, year_range=(2018, 2019)
- ... )
- >>> query.strhash()
- 'a6571bb7be65984c6627f545cab9fc767fce6d07'
+ ... ).strhash()
+ '275c58cac77c5ea9ccd34ab870f59627ab98e73c'
+ >>> AlbumSearchQuery(
+ ... AlbumSearchQuery.Type.YEAR_RANGE, year_range=(2018, 2020)
+ ... ).strhash()
+ 'e5dc424e8fc3b7d9ff7878b38cbf2c9fbdc19ec2'
+ >>> AlbumSearchQuery(AlbumSearchQuery.Type.STARRED).strhash()
+ '861b6ff011c97d53945ca89576463d0aeb78e3d2'
"""
if not self._strhash:
- self._strhash = hashlib.sha1(
- bytes(
- json.dumps((self.type.value, self.year_range, self.genre.name)),
- "utf8",
- )
- ).hexdigest()
+ hash_tuple: Tuple[Any, ...] = (self.type.value,)
+ if self.type == AlbumSearchQuery.Type.YEAR_RANGE:
+ hash_tuple += (self.year_range,)
+ elif self.type == AlbumSearchQuery.Type.GENRE:
+ hash_tuple += (self.genre.name,)
+ self._strhash = hashlib.sha1(bytes(str(hash_tuple), "utf8")).hexdigest()
return self._strhash
@@ -287,6 +291,10 @@ class Adapter(abc.ABC):
"""
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.
+ # ==================================================================================
@property
def is_networked(self) -> bool:
"""
@@ -296,58 +304,66 @@ class Adapter(abc.ABC):
"""
return True
- # Availability Properties
- # These properties determine if what things the adapter can be used to do
- # at the current moment.
- # ==================================================================================
+ def on_offline_mode_change(self, offline_mode: bool):
+ """
+ This function should be used to handle any operations that need to be performed
+ when Sublime Music goes from online to offline mode or vice versa.
+ """
+
@property
@abc.abstractmethod
- def can_service_requests(self) -> bool:
+ def ping_status(self) -> bool:
"""
- Specifies whether or not the adapter can currently service requests. If this is
- ``False``, none of the other data retrieval functions are expected to work.
+ If the adapter :class:`is_networked`, then this function should return whether
+ or not the server can be pinged. This function must provide an answer
+ *instantly* (it can't actually ping the server). This function is called *very*
+ often, and even a few milliseconds delay stacks up quickly and can block the UI
+ thread.
- This property must be server *instantly*. This function is called *very* often,
- and even a few milliseconds delay stacks up quickly and can block the UI thread.
-
- For example, if your adapter requires access to an external service, on option
- is to ping the server every few seconds and cache the result of the ping and use
- that as the return value of this function.
+ One option is to ping the server every few seconds and cache the result of the
+ ping and use that as the result of this function.
"""
+ # Availability Properties
+ # These properties determine if what things the adapter can be used to do. These
+ # properties can be dynamic based on things such as underlying API version, or other
+ # factors like that. However, these properties should not be dependent on the
+ # connection state. After the initial sync, these properties are expected to be
+ # constant.
+ # ==================================================================================
# Playlists
@property
def can_get_playlists(self) -> bool:
"""
- Whether :class:`get_playlist` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_playlist`.
"""
return False
@property
def can_get_playlist_details(self) -> bool:
"""
- Whether :class:`get_playlist_details` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_playlist_details`.
"""
return False
@property
def can_create_playlist(self) -> bool:
"""
- Whether :class:`create_playlist` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`create_playlist`.
"""
return False
@property
def can_update_playlist(self) -> bool:
"""
- Whether :class:`update_playlist` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`update_playlist`.
"""
return False
@property
def can_delete_playlist(self) -> bool:
"""
- Whether :class:`delete_playlist` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`delete_playlist`.
"""
return False
@@ -367,20 +383,20 @@ class Adapter(abc.ABC):
@property
def can_get_cover_art_uri(self) -> bool:
"""
- Whether :class:`get_cover_art_uri` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_cover_art_uri`.
"""
@property
def can_stream(self) -> bool:
"""
- Whether or not the adapter can provide a stream URI right now.
+ Whether or not the adapter can provide a stream URI.
"""
return False
@property
def can_get_song_uri(self) -> bool:
"""
- Whether :class:`get_song_uri` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_song_uri`.
"""
return False
@@ -388,14 +404,14 @@ class Adapter(abc.ABC):
@property
def can_get_song_details(self) -> bool:
"""
- Whether :class:`get_song_details` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_song_details`.
"""
return False
@property
def can_scrobble_song(self) -> bool:
"""
- Whether :class:`scrobble_song` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`scrobble_song`.
"""
return False
@@ -413,21 +429,21 @@ class Adapter(abc.ABC):
@property
def can_get_artists(self) -> bool:
"""
- Whether :class:`get_aritsts` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_aritsts`.
"""
return False
@property
def can_get_artist(self) -> bool:
"""
- Whether :class:`get_aritst` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_aritst`.
"""
return False
@property
def can_get_ignored_articles(self) -> bool:
"""
- Whether :class:`get_ignored_articles` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_ignored_articles`.
"""
return False
@@ -435,14 +451,14 @@ class Adapter(abc.ABC):
@property
def can_get_albums(self) -> bool:
"""
- Whether :class:`get_albums` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_albums`.
"""
return False
@property
def can_get_album(self) -> bool:
"""
- Whether :class:`get_album` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_album`.
"""
return False
@@ -450,7 +466,7 @@ class Adapter(abc.ABC):
@property
def can_get_directory(self) -> bool:
"""
- Whether :class:`get_directory` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_directory`.
"""
return False
@@ -458,7 +474,7 @@ class Adapter(abc.ABC):
@property
def can_get_genres(self) -> bool:
"""
- Whether :class:`get_genres` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_genres`.
"""
return False
@@ -466,14 +482,14 @@ class Adapter(abc.ABC):
@property
def can_get_play_queue(self) -> bool:
"""
- Whether :class:`get_play_queue` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`get_play_queue`.
"""
return False
@property
def can_save_play_queue(self) -> bool:
"""
- Whether :class:`save_play_queue` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`save_play_queue`.
"""
return False
@@ -481,7 +497,7 @@ class Adapter(abc.ABC):
@property
def can_search(self) -> bool:
"""
- Whether :class:`search` can be called on the adapter right now.
+ Whether or not the adapter supports :class:`search`.
"""
return False
@@ -691,7 +707,7 @@ class Adapter(abc.ABC):
def save_play_queue(
self,
- song_ids: Sequence[int],
+ song_ids: Sequence[str],
current_song_index: int = None,
position: timedelta = None,
):
@@ -712,7 +728,7 @@ class Adapter(abc.ABC):
:returns: A :class:`sublime.adapters.api_objects.SearchResult` object
representing the results of the search.
"""
- raise self._check_can_error("can_save_play_queue")
+ raise self._check_can_error("search")
@staticmethod
def _check_can_error(method_name: str) -> NotImplementedError:
@@ -751,6 +767,8 @@ class CachingAdapter(Adapter):
:param is_cache: whether or not the adapter is being used as a cache.
"""
+ ping_status = True
+
# Data Ingestion Methods
# ==================================================================================
class CachedDataKey(Enum):
@@ -769,6 +787,10 @@ class CachingAdapter(Adapter):
SONG_FILE = "song_file"
SONG_FILE_PERMANENT = "song_file_permanent"
+ # These are only for clearing the cache, and will only do deletion
+ ALL_SONGS = "all_songs"
+ EVERYTHING = "everything"
+
@abc.abstractmethod
def ingest_new_data(self, data_key: CachedDataKey, param: Optional[str], data: Any):
"""
diff --git a/sublime/adapters/filesystem/adapter.py b/sublime/adapters/filesystem/adapter.py
index c0759b4..9a8cce4 100644
--- a/sublime/adapters/filesystem/adapter.py
+++ b/sublime/adapters/filesystem/adapter.py
@@ -79,7 +79,6 @@ class FilesystemAdapter(CachingAdapter):
# ==================================================================================
can_be_cached = False # Can't be cached (there's no need).
is_networked = False # Doesn't access the network.
- can_service_requests = True # Can always be used to service requests.
# TODO (#200) make these dependent on cache state. Need to do this kinda efficiently
can_get_cover_art_uri = True
@@ -182,13 +181,16 @@ class FilesystemAdapter(CachingAdapter):
return obj
def _compute_song_filename(self, cache_info: models.CacheInfo) -> Path:
- if path_str := cache_info.path:
- # Make sure that the path is somewhere in the cache directory and a
- # malicious server (or MITM attacker) isn't trying to override files in
- # other parts of the system.
- path = self.music_dir.joinpath(path_str)
- if self.music_dir in path.parents:
- return path
+ try:
+ if path_str := cache_info.path:
+ # Make sure that the path is somewhere in the cache directory and a
+ # malicious server (or MITM attacker) isn't trying to override files in
+ # other parts of the system.
+ path = self.music_dir.joinpath(path_str)
+ if self.music_dir in path.parents:
+ return path
+ except Exception:
+ pass
# Fall back to using the song file hash as the filename. This shouldn't happen
# with good servers, but just to be safe.
@@ -200,30 +202,37 @@ class FilesystemAdapter(CachingAdapter):
self, song_ids: Sequence[str]
) -> Dict[str, SongCacheStatus]:
def compute_song_cache_status(song: models.Song) -> SongCacheStatus:
- file = song.file
- if self._compute_song_filename(file).exists():
- if file.valid:
- if file.cache_permanently:
- return SongCacheStatus.PERMANENTLY_CACHED
- return SongCacheStatus.CACHED
+ try:
+ file = song.file
+ if self._compute_song_filename(file).exists():
+ if file.valid:
+ if file.cache_permanently:
+ return SongCacheStatus.PERMANENTLY_CACHED
+ return SongCacheStatus.CACHED
+
+ # The file is on disk, but marked as stale.
+ return SongCacheStatus.CACHED_STALE
+ except Exception:
+ pass
- # The file is on disk, but marked as stale.
- return SongCacheStatus.CACHED_STALE
return SongCacheStatus.NOT_CACHED
+ cached_statuses = {song_id: SongCacheStatus.NOT_CACHED for song_id in song_ids}
try:
file_models = models.CacheInfo.select().where(
models.CacheInfo.cache_key == KEYS.SONG_FILE
)
song_models = models.Song.select().where(models.Song.id.in_(song_ids))
- return {
- s.id: compute_song_cache_status(s)
- for s in prefetch(song_models, file_models)
- }
+ cached_statuses.update(
+ {
+ s.id: compute_song_cache_status(s)
+ for s in prefetch(song_models, file_models)
+ }
+ )
except Exception:
pass
- return {song_id: SongCacheStatus.NOT_CACHED for song_id in song_ids}
+ return cached_statuses
_playlists = None
@@ -251,9 +260,11 @@ class FilesystemAdapter(CachingAdapter):
)
if cover_art:
filename = self.cover_art_dir.joinpath(str(cover_art.file_hash))
- if cover_art.valid and filename.exists():
- return str(filename)
- raise CacheMissError(partial_data=str(filename))
+ if filename.exists():
+ if cover_art.valid:
+ return str(filename)
+ else:
+ raise CacheMissError(partial_data=str(filename))
raise CacheMissError()
@@ -269,9 +280,11 @@ class FilesystemAdapter(CachingAdapter):
if (song_file := song.file) and (
filename := self._compute_song_filename(song_file)
):
- if song_file.valid and filename.exists():
- return str(filename)
- raise CacheMissError(partial_data=str(filename))
+ if filename.exists():
+ if song_file.valid:
+ return str(filename)
+ else:
+ raise CacheMissError(partial_data=str(filename))
except models.CacheInfo.DoesNotExist:
pass
@@ -857,4 +870,25 @@ class FilesystemAdapter(CachingAdapter):
if cache_info:
self._compute_song_filename(cache_info).unlink(missing_ok=True)
- cache_info.delete_instance()
+ elif data_key == CachingAdapter.CachedDataKey.ALL_SONGS:
+ shutil.rmtree(str(self.music_dir))
+ shutil.rmtree(str(self.cover_art_dir))
+ self.music_dir.mkdir(parents=True, exist_ok=True)
+ self.cover_art_dir.mkdir(parents=True, exist_ok=True)
+
+ models.CacheInfo.update({"valid": False}).where(
+ models.CacheInfo.cache_key == CachingAdapter.CachedDataKey.SONG_FILE
+ ).execute()
+ models.CacheInfo.update({"valid": False}).where(
+ models.CacheInfo.cache_key
+ == CachingAdapter.CachedDataKey.COVER_ART_FILE
+ ).execute()
+
+ elif data_key == CachingAdapter.CachedDataKey.EVERYTHING:
+ self._do_delete_data(CachingAdapter.CachedDataKey.ALL_SONGS, None)
+ for table in models.ALL_TABLES:
+ table.truncate_table()
+
+ if cache_info:
+ cache_info.valid = False
+ cache_info.save()
diff --git a/sublime/adapters/filesystem/sqlite_extensions.py b/sublime/adapters/filesystem/sqlite_extensions.py
index b5b4672..585fb78 100644
--- a/sublime/adapters/filesystem/sqlite_extensions.py
+++ b/sublime/adapters/filesystem/sqlite_extensions.py
@@ -75,8 +75,9 @@ class SortedManyToManyFieldAccessor(ManyToManyFieldAccessor):
if instance is not None:
if not force_query and self.src_fk.backref != "+":
backref = getattr(instance, self.src_fk.backref)
- if isinstance(backref, list):
- return [getattr(obj, self.dest_fk.name) for obj in backref]
+ assert not isinstance(backref, list)
+ # if isinstance(backref, list):
+ # return [getattr(obj, self.dest_fk.name) for obj in backref]
src_id = getattr(instance, self.src_fk.rel_field.name)
return (
diff --git a/sublime/adapters/images/default-album-art.png b/sublime/adapters/images/default-album-art.png
index a5f2e11..2db4342 100644
Binary files a/sublime/adapters/images/default-album-art.png and b/sublime/adapters/images/default-album-art.png differ
diff --git a/sublime/adapters/images/default-album-art.svg b/sublime/adapters/images/default-album-art.svg
index 9fce3a0..b2fcb5f 100644
--- a/sublime/adapters/images/default-album-art.svg
+++ b/sublime/adapters/images/default-album-art.svg
@@ -7,14 +7,17 @@
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
- fill="#000000"
- viewBox="0 0 24 24"
- width="384px"
- height="384px"
- version="1.1"
+ inkscape:export-ydpi="103.984"
+ inkscape:export-xdpi="103.984"
+ inkscape:export-filename="/home/sumner/projects/sublime-music/sublime/adapters/images/default-album-art.png"
+ inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
+ sodipodi:docname="default-album-art.svg"
id="svg6"
- sodipodi:docname="icons8-music.svg"
- inkscape:version="0.92.4 5da689c313, 2019-01-14">
+ version="1.1"
+ height="384px"
+ width="384px"
+ viewBox="0 0 24 24"
+ fill="#000000">
@@ -29,71 +32,72 @@
+ inkscape:window-y="64"
+ inkscape:window-x="0"
+ inkscape:cy="255.37351"
+ inkscape:cx="249.24086"
+ inkscape:zoom="1.7383042"
+ showgrid="false"
+ id="namedview8"
+ inkscape:window-height="1374"
+ inkscape:window-width="2556"
+ inkscape:pageshadow="2"
+ inkscape:pageopacity="0"
+ guidetolerance="10"
+ gridtolerance="10"
+ objecttolerance="10"
+ borderopacity="1"
+ bordercolor="#666666"
+ pagecolor="#ffffff">
+ orientation="0,384"
+ position="0,0" />
+ orientation="-384,0"
+ position="24,0" />
+ orientation="0,-384"
+ position="24,24" />
+ orientation="384,0"
+ position="0,24" />
+ x="0"
+ height="24"
+ width="24"
+ id="rect22" />
+ style="stroke:#b3b3b3"
+ id="g26">
+ id="path2" />
+ stroke="#000000"
+ stroke-miterlimit="10"
+ stroke-width="2"
+ d="M12 18L12 4 18 4 18 8 13 8"
+ id="path4" />
diff --git a/sublime/adapters/manager.py b/sublime/adapters/manager.py
index 8b84da3..12a6a15 100644
--- a/sublime/adapters/manager.py
+++ b/sublime/adapters/manager.py
@@ -29,8 +29,6 @@ from typing import (
import requests
-from sublime.config import AppConfiguration
-
from .adapter_base import (
Adapter,
AlbumSearchQuery,
@@ -59,6 +57,10 @@ if delay_str := os.environ.get("REQUEST_DELAY"):
else:
REQUEST_DELAY = (float(delay_str), float(delay_str))
+NETWORK_ALWAYS_ERROR: bool = False
+if always_error := os.environ.get("NETWORK_ALWAYS_ERROR"):
+ NETWORK_ALWAYS_ERROR = True
+
T = TypeVar("T")
@@ -164,6 +166,7 @@ class AdapterManager:
executor: ThreadPoolExecutor = ThreadPoolExecutor()
download_executor: ThreadPoolExecutor = ThreadPoolExecutor()
is_shutting_down: bool = False
+ _offline_mode: bool = False
@dataclass
class _AdapterManagerInternal:
@@ -206,6 +209,16 @@ class AdapterManager:
assert AdapterManager._instance
return Result(AdapterManager._instance.ground_truth_adapter.initial_sync)
+ @staticmethod
+ def ground_truth_adapter_is_networked() -> bool:
+ assert AdapterManager._instance
+ return AdapterManager._instance.ground_truth_adapter.is_networked
+
+ @staticmethod
+ def get_ping_status() -> bool:
+ assert AdapterManager._instance
+ return AdapterManager._instance.ground_truth_adapter.ping_status
+
@staticmethod
def shutdown():
logging.info("AdapterManager shutdown start")
@@ -218,11 +231,18 @@ class AdapterManager:
logging.info("AdapterManager shutdown complete")
@staticmethod
- def reset(config: AppConfiguration):
+ def reset(config: Any):
+
+ from sublime.config import AppConfiguration
+
+ assert isinstance(config, AppConfiguration)
+
# First, shutdown the current one...
if AdapterManager._instance:
AdapterManager._instance.shutdown()
+ AdapterManager._offline_mode = config.offline_mode
+
# TODO (#197): actually do stuff with the config to determine which adapters to
# create, etc.
assert config.server is not None
@@ -257,27 +277,27 @@ class AdapterManager:
concurrent_download_limit=config.concurrent_download_limit,
)
+ @staticmethod
+ def on_offline_mode_change(offline_mode: bool):
+ AdapterManager._offline_mode = offline_mode
+ if (instance := AdapterManager._instance) and (
+ (ground_truth_adapter := instance.ground_truth_adapter).is_networked
+ ):
+ ground_truth_adapter.on_offline_mode_change(offline_mode)
+
# Data Helper Methods
# ==================================================================================
TAdapter = TypeVar("TAdapter", bound=Adapter)
@staticmethod
- def _adapter_can_do(adapter: Optional[TAdapter], action_name: str) -> bool:
- return (
- adapter is not None
- and adapter.can_service_requests
- and getattr(adapter, f"can_{action_name}", False)
- )
-
- @staticmethod
- def _cache_can_do(action_name: str) -> bool:
- return AdapterManager._instance is not None and AdapterManager._adapter_can_do(
- AdapterManager._instance.caching_adapter, action_name
- )
+ def _adapter_can_do(adapter: TAdapter, action_name: str) -> bool:
+ return adapter is not None and getattr(adapter, f"can_{action_name}", False)
@staticmethod
def _ground_truth_can_do(action_name: str) -> bool:
- return AdapterManager._instance is not None and AdapterManager._adapter_can_do(
+ if not AdapterManager._instance:
+ return False
+ return AdapterManager._adapter_can_do(
AdapterManager._instance.ground_truth_adapter, action_name
)
@@ -285,22 +305,20 @@ class AdapterManager:
def _can_use_cache(force: bool, action_name: str) -> bool:
if force:
return False
- return AdapterManager._cache_can_do(action_name)
-
- @staticmethod
- def _any_adapter_can_do(action_name: str) -> bool:
- if AdapterManager._instance is None:
- return False
-
- return AdapterManager._ground_truth_can_do(
- action_name
- ) or AdapterManager._cache_can_do(action_name)
+ return (
+ AdapterManager._instance is not None
+ and AdapterManager._instance.caching_adapter is not None
+ and AdapterManager._adapter_can_do(
+ AdapterManager._instance.caching_adapter, action_name
+ )
+ )
@staticmethod
def _create_ground_truth_result(
function_name: str,
*params: Any,
before_download: Callable[[], None] = None,
+ partial_data: Any = None,
**kwargs,
) -> Result:
"""
@@ -309,10 +327,19 @@ class AdapterManager:
def future_fn() -> Any:
assert AdapterManager._instance
+ if (
+ AdapterManager._offline_mode
+ and AdapterManager._instance.ground_truth_adapter.is_networked
+ ):
+ raise CacheMissError(partial_data=partial_data)
+
if before_download:
before_download()
fn = getattr(AdapterManager._instance.ground_truth_adapter, function_name)
- return fn(*params, **kwargs)
+ try:
+ return fn(*params, **kwargs)
+ except Exception:
+ raise CacheMissError(partial_data=partial_data)
return Result(future_fn)
@@ -359,10 +386,13 @@ class AdapterManager:
if REQUEST_DELAY is not None:
delay = random.uniform(*REQUEST_DELAY)
logging.info(
- f"REQUEST_DELAY enabled. Pausing for {delay} seconds" # noqa: E501
+ f"REQUEST_DELAY enabled. Pausing for {delay} seconds"
)
sleep(delay)
+ if NETWORK_ALWAYS_ERROR:
+ raise Exception("NETWORK_ALWAYS_ERROR enabled")
+
data = requests.get(uri)
# TODO (#122): make better
@@ -398,7 +428,7 @@ class AdapterManager:
assert AdapterManager._instance
assert AdapterManager._instance.caching_adapter
AdapterManager._instance.caching_adapter.ingest_new_data(
- cache_key, param, f.result(),
+ cache_key, param, f.result()
)
return future_finished
@@ -484,24 +514,22 @@ class AdapterManager:
cache_key, param_str
)
- # TODO (#122) If any of the following fails, do we want to return what the
- # caching adapter has?
-
- # TODO (#188): don't short circuit if not allow_download because it could be the
- # filesystem adapter.
- if not allow_download or not AdapterManager._ground_truth_can_do(function_name):
+ if (
+ not allow_download
+ and AdapterManager._instance.ground_truth_adapter.is_networked
+ ) or not AdapterManager._ground_truth_can_do(function_name):
logging.info(f"END: NO DOWNLOAD: {function_name}")
- if partial_data:
- # TODO (#122) indicate that this is partial data. Probably just re-throw
- # here?
- logging.debug("partial_data exists, returning", partial_data)
- return Result(cast(AdapterManager.R, partial_data))
- raise Exception(f"No adapters can service {function_name} at the moment.")
+
+ def cache_miss_result():
+ raise CacheMissError(partial_data=partial_data)
+
+ return Result(cache_miss_result)
result: Result[AdapterManager.R] = AdapterManager._create_ground_truth_result(
function_name,
*((param,) if param is not None else ()),
before_download=before_download,
+ partial_data=partial_data,
**kwargs,
)
@@ -512,7 +540,6 @@ class AdapterManager:
)
if on_result_finished:
- # TODO (#122): figure out a way to pass partial data here
result.add_done_callback(on_result_finished)
logging.info(f"END: {function_name}")
@@ -523,27 +550,27 @@ class AdapterManager:
# ==================================================================================
@staticmethod
def can_get_playlists() -> bool:
- return AdapterManager._any_adapter_can_do("get_playlists")
+ return AdapterManager._ground_truth_can_do("get_playlists")
@staticmethod
def can_get_playlist_details() -> bool:
- return AdapterManager._any_adapter_can_do("get_playlist_details")
+ return AdapterManager._ground_truth_can_do("get_playlist_details")
@staticmethod
def can_create_playlist() -> bool:
- return AdapterManager._any_adapter_can_do("create_playlist")
+ return AdapterManager._ground_truth_can_do("create_playlist")
@staticmethod
def can_update_playlist() -> bool:
- return AdapterManager._any_adapter_can_do("update_playlist")
+ return AdapterManager._ground_truth_can_do("update_playlist")
@staticmethod
def can_delete_playlist() -> bool:
- return AdapterManager._any_adapter_can_do("delete_playlist")
+ return AdapterManager._ground_truth_can_do("delete_playlist")
@staticmethod
def can_get_song_filename_or_stream() -> bool:
- return AdapterManager._any_adapter_can_do("get_song_uri")
+ return AdapterManager._ground_truth_can_do("get_song_uri")
@staticmethod
def can_batch_download_songs() -> bool:
@@ -552,23 +579,23 @@ class AdapterManager:
@staticmethod
def can_get_genres() -> bool:
- return AdapterManager._any_adapter_can_do("get_genres")
+ return AdapterManager._ground_truth_can_do("get_genres")
@staticmethod
def can_scrobble_song() -> bool:
- return AdapterManager._any_adapter_can_do("scrobble_song")
+ return AdapterManager._ground_truth_can_do("scrobble_song")
@staticmethod
def can_get_artists() -> bool:
- return AdapterManager._any_adapter_can_do("get_artists")
+ return AdapterManager._ground_truth_can_do("get_artists")
@staticmethod
def can_get_artist() -> bool:
- return AdapterManager._any_adapter_can_do("get_artist")
+ return AdapterManager._ground_truth_can_do("get_artist")
@staticmethod
def can_get_directory() -> bool:
- return AdapterManager._any_adapter_can_do("get_directory")
+ return AdapterManager._ground_truth_can_do("get_directory")
@staticmethod
def can_get_play_queue() -> bool:
@@ -580,7 +607,7 @@ class AdapterManager:
@staticmethod
def can_search() -> bool:
- return AdapterManager._any_adapter_can_do("search")
+ return AdapterManager._ground_truth_can_do("search")
# Data Retrieval Methods
# ==================================================================================
@@ -666,9 +693,15 @@ class AdapterManager:
@staticmethod
def delete_playlist(playlist_id: str):
- # TODO (#190): make non-blocking?
assert AdapterManager._instance
- AdapterManager._instance.ground_truth_adapter.delete_playlist(playlist_id)
+ ground_truth_adapter = AdapterManager._instance.ground_truth_adapter
+ if AdapterManager._offline_mode and ground_truth_adapter.is_networked:
+ raise AssertionError(
+ "You should never call delete_playlist in offline mode"
+ )
+
+ # TODO (#190): make non-blocking?
+ ground_truth_adapter.delete_playlist(playlist_id)
if AdapterManager._instance.caching_adapter:
AdapterManager._instance.caching_adapter.delete_data(
@@ -705,6 +738,10 @@ class AdapterManager:
assert AdapterManager._instance
+ # If the ground truth adapter can't provide cover art, just give up immediately.
+ if not AdapterManager._ground_truth_can_do("get_cover_art_uri"):
+ return Result(existing_cover_art_filename)
+
# There could be partial data if the cover art exists, but for some reason was
# marked out-of-date.
if AdapterManager._can_use_cache(force, "get_cover_art_uri"):
@@ -724,15 +761,15 @@ class AdapterManager:
f'Error on {"get_cover_art_uri"} retrieving from cache.'
)
- if not allow_download:
- return Result(existing_cover_art_filename)
-
if AdapterManager._instance.caching_adapter and force:
AdapterManager._instance.caching_adapter.invalidate_data(
CachingAdapter.CachedDataKey.COVER_ART_FILE, cover_art_id
)
- if not AdapterManager._ground_truth_can_do("get_cover_art_uri"):
+ if not allow_download or (
+ AdapterManager._offline_mode
+ and AdapterManager._instance.ground_truth_adapter.is_networked
+ ):
return Result(existing_cover_art_filename)
future: Result[str] = Result(
@@ -759,7 +796,7 @@ class AdapterManager:
# TODO (#189): allow this to take a set of schemes
@staticmethod
def get_song_filename_or_stream(
- song: Song, format: str = None, force_stream: bool = False,
+ song: Song, format: str = None, force_stream: bool = False
) -> str:
assert AdapterManager._instance
cached_song_filename = None
@@ -778,17 +815,15 @@ class AdapterManager:
f'Error on {"get_song_filename_or_stream"} retrieving from cache.'
)
- if not AdapterManager._ground_truth_can_do("get_song_uri"):
- if force_stream or cached_song_filename is None:
- raise Exception("Can't stream the song.")
- return cached_song_filename
-
- # TODO (subsonic-extensions-api/specification#2) implement subsonic extension to
- # get the hash of the song and compare here. That way of the cache gets blown
- # away, but not the song files, it will not have to re-download.
-
- if force_stream and not AdapterManager._ground_truth_can_do("stream"):
- raise Exception("Can't stream the song.")
+ if (
+ not AdapterManager._ground_truth_can_do("stream")
+ or not AdapterManager._ground_truth_can_do("get_song_uri")
+ or (
+ AdapterManager._instance.ground_truth_adapter.is_networked
+ and AdapterManager._offline_mode
+ )
+ ):
+ raise CacheMissError(partial_data=cached_song_filename)
return AdapterManager._instance.ground_truth_adapter.get_song_uri(
song.id, AdapterManager._get_scheme(), stream=True,
@@ -803,6 +838,13 @@ class AdapterManager:
delay: float = 0.0,
) -> Result[None]:
assert AdapterManager._instance
+ if (
+ AdapterManager._offline_mode
+ and AdapterManager._instance.ground_truth_adapter.is_networked
+ ):
+ raise AssertionError(
+ "You should never call batch_download_songs in offline mode"
+ )
# This only really makes sense if we have a caching_adapter.
if not AdapterManager._instance.caching_adapter:
@@ -811,7 +853,11 @@ class AdapterManager:
cancelled = False
def do_download_song(song_id: str):
- if AdapterManager.is_shutting_down or cancelled:
+ if (
+ AdapterManager.is_shutting_down
+ or AdapterManager._offline_mode
+ or cancelled
+ ):
return
assert AdapterManager._instance
@@ -832,6 +878,7 @@ class AdapterManager:
before_download(song_id)
# Download the song.
+ # TODO (#64) handle download errors?
song_tmp_filename = AdapterManager._create_download_fn(
AdapterManager._instance.ground_truth_adapter.get_song_uri(
song_id, AdapterManager._get_scheme()
@@ -859,16 +906,16 @@ class AdapterManager:
def do_batch_download_songs():
sleep(delay)
for song_id in song_ids:
- if cancelled:
+ if (
+ AdapterManager.is_shutting_down
+ or AdapterManager._offline_mode
+ or cancelled
+ ):
return
# Only allow a certain number of songs to be downloaded
# simultaneously.
AdapterManager._instance.download_limiter_semaphore.acquire()
- # Prevents further songs from being downloaded.
- if AdapterManager.is_shutting_down:
- break
-
result = Result(do_download_song, song_id, is_download=True)
if one_at_a_time:
@@ -963,7 +1010,7 @@ class AdapterManager:
@staticmethod
def _get_ignored_articles(use_ground_truth_adapter: bool) -> Set[str]:
# TODO (#21) get this at first startup.
- if not AdapterManager._any_adapter_can_do("get_ignored_articles"):
+ if not AdapterManager._ground_truth_can_do("get_ignored_articles"):
return set()
try:
ignored_articles: Set[str] = AdapterManager._get_from_cache_or_ground_truth(
@@ -1096,7 +1143,7 @@ class AdapterManager:
@staticmethod
def save_play_queue(
- song_ids: Sequence[int],
+ song_ids: Sequence[str],
current_song_index: int = None,
position: timedelta = None,
):
@@ -1133,7 +1180,7 @@ class AdapterManager:
sleep(0.3)
if cancelled:
logging.info(f"Cancelled query {query} before caching adapter")
- return False
+ return True
assert AdapterManager._instance
@@ -1158,11 +1205,11 @@ class AdapterManager:
# Wait longer to see if the user types anything else so we don't peg the
# server with tons of requests.
sleep(
- 1 if AdapterManager._instance.ground_truth_adapter.is_networked else 0.2
+ 1 if AdapterManager._instance.ground_truth_adapter.is_networked else 0.3
)
if cancelled:
logging.info(f"Cancelled query {query} before server results")
- return False
+ return True
try:
ground_truth_search_results = AdapterManager._instance.ground_truth_adapter.search( # noqa: E501
@@ -1182,7 +1229,7 @@ class AdapterManager:
ground_truth_search_results,
)
- return True
+ return False
# When the future is cancelled (this will happen if a new search is created),
# set cancelled to True so that the search function can abort.
@@ -1209,3 +1256,21 @@ class AdapterManager:
else cached_statuses[song_id]
for song_id in song_ids
]
+
+ @staticmethod
+ def clear_song_cache():
+ assert AdapterManager._instance
+ if not AdapterManager._instance.caching_adapter:
+ return
+ AdapterManager._instance.caching_adapter.delete_data(
+ CachingAdapter.CachedDataKey.ALL_SONGS, None
+ )
+
+ @staticmethod
+ def clear_entire_cache():
+ assert AdapterManager._instance
+ if not AdapterManager._instance.caching_adapter:
+ return
+ AdapterManager._instance.caching_adapter.delete_data(
+ CachingAdapter.CachedDataKey.EVERYTHING, None
+ )
diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py
index eda32a1..ed78cb6 100644
--- a/sublime/adapters/subsonic/adapter.py
+++ b/sublime/adapters/subsonic/adapter.py
@@ -50,6 +50,10 @@ if delay_str := os.environ.get("REQUEST_DELAY"):
else:
REQUEST_DELAY = (float(delay_str), float(delay_str))
+NETWORK_ALWAYS_ERROR: bool = False
+if always_error := os.environ.get("NETWORK_ALWAYS_ERROR"):
+ NETWORK_ALWAYS_ERROR = True
+
class SubsonicAdapter(Adapter):
"""
@@ -122,46 +126,61 @@ class SubsonicAdapter(Adapter):
self.disable_cert_verify = config.get("disable_cert_verify")
self.is_shutting_down = False
- self.ping_process = multiprocessing.Process(target=self._check_ping_thread)
- self.ping_process.start()
# TODO (#112): support XML?
+ _ping_process: Optional[multiprocessing.Process] = None
+ _offline_mode = False
+
def initial_sync(self):
- # Wait for the ping to happen.
- tries = 0
- while not self._server_available.value and tries < 5:
- self._set_ping_status()
- tries += 1
+ # Try to ping the server five times using exponential backoff (2^5 = 32s).
+ self._exponential_backoff(5)
def shutdown(self):
- self.ping_process.terminate()
+ if self._ping_process:
+ self._ping_process.terminate()
# Availability Properties
# ==================================================================================
_server_available = multiprocessing.Value("b", False)
+ _last_ping_timestamp = multiprocessing.Value("d", 0.0)
- def _check_ping_thread(self):
- # TODO (#198): also use other requests in place of ping if they come in. If the
- # time since the last successful request is high, then do another ping.
- # TODO (#198): also use NM to detect when the connection changes and update
- # accordingly.
+ def _exponential_backoff(self, n: int):
+ logging.info(f"Starting Exponential Backoff: n={n}")
+ if self._ping_process:
+ self._ping_process.terminate()
- while True:
- self._set_ping_status()
- sleep(15)
+ self._ping_process = multiprocessing.Process(
+ target=self._check_ping_thread, args=(n,)
+ )
+ self._ping_process.start()
- def _set_ping_status(self):
- try:
- # Try to ping the server with a timeout of 2 seconds.
- self._get_json(self._make_url("ping"), timeout=2)
- self._server_available.value = True
- except Exception:
- logging.exception(f"Could not connect to {self.hostname}")
- self._server_available.value = False
+ def _check_ping_thread(self, n: int):
+ i = 0
+ while i < n and not self._offline_mode and not self._server_available.value:
+ try:
+ self._set_ping_status(timeout=2 * (i + 1))
+ except Exception:
+ pass
+ sleep(2 ** i)
+ i += 1
+
+ def _set_ping_status(self, timeout: int = 2):
+ logging.info(f"SET PING STATUS timeout={timeout}")
+ now = datetime.now().timestamp()
+ if now - self._last_ping_timestamp.value < 15:
+ return
+
+ # Try to ping the server.
+ self._get_json(
+ self._make_url("ping"), timeout=timeout, is_exponential_backoff_ping=True,
+ )
+
+ def on_offline_mode_change(self, offline_mode: bool):
+ self._offline_mode = offline_mode
@property
- def can_service_requests(self) -> bool:
+ def ping_status(self) -> bool:
return self._server_available.value
# TODO (#199) make these way smarter
@@ -232,39 +251,59 @@ class SubsonicAdapter(Adapter):
url: str,
timeout: Union[float, Tuple[float, float], None] = None,
# TODO (#122): retry count
+ is_exponential_backoff_ping: bool = False,
**params,
) -> Any:
params = {**self._get_params(), **params}
logging.info(f"[START] get: {url}")
- if REQUEST_DELAY:
- delay = random.uniform(*REQUEST_DELAY)
- logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds")
- sleep(delay)
- if timeout:
- if type(timeout) == tuple:
- if cast(Tuple[float, float], timeout)[0] > delay:
- raise TimeoutError("DUMMY TIMEOUT ERROR")
- else:
- if cast(float, timeout) > delay:
- raise TimeoutError("DUMMY TIMEOUT ERROR")
+ try:
+ if REQUEST_DELAY is not None:
+ delay = random.uniform(*REQUEST_DELAY)
+ logging.info(f"REQUEST_DELAY enabled. Pausing for {delay} seconds")
+ sleep(delay)
+ if timeout:
+ if type(timeout) == tuple:
+ if delay > cast(Tuple[float, float], timeout)[0]:
+ raise TimeoutError("DUMMY TIMEOUT ERROR")
+ else:
+ if delay > cast(float, timeout):
+ raise TimeoutError("DUMMY TIMEOUT ERROR")
- # Deal with datetime parameters (convert to milliseconds since 1970)
- for k, v in params.items():
- if isinstance(v, datetime):
- params[k] = int(v.timestamp() * 1000)
+ if NETWORK_ALWAYS_ERROR:
+ raise Exception("NETWORK_ALWAYS_ERROR enabled")
- if self._is_mock:
- logging.info("Using mock data")
- return self._get_mock_data()
+ # Deal with datetime parameters (convert to milliseconds since 1970)
+ for k, v in params.items():
+ if isinstance(v, datetime):
+ params[k] = int(v.timestamp() * 1000)
- result = requests.get(
- url, params=params, verify=not self.disable_cert_verify, timeout=timeout
- )
+ if self._is_mock:
+ logging.info("Using mock data")
+ result = self._get_mock_data()
+ else:
+ result = requests.get(
+ url,
+ params=params,
+ verify=not self.disable_cert_verify,
+ timeout=timeout,
+ )
- # TODO (#122): make better
- if result.status_code != 200:
- raise Exception(f"[FAIL] get: {url} status={result.status_code}")
+ # TODO (#122): make better
+ if result.status_code != 200:
+ raise Exception(f"[FAIL] get: {url} status={result.status_code}")
+
+ # Any time that a server request succeeds, then we win.
+ self._server_available.value = True
+ self._last_ping_timestamp.value = datetime.now().timestamp()
+
+ except Exception:
+ logging.exception(f"get: {url} failed")
+ self._server_available.value = False
+ self._last_ping_timestamp.value = datetime.now().timestamp()
+ if not is_exponential_backoff_ping:
+ self._exponential_backoff(5)
+ raise
logging.info(f"[FINISH] get: {url}")
return result
@@ -273,6 +312,7 @@ class SubsonicAdapter(Adapter):
self,
url: str,
timeout: Union[float, Tuple[float, float], None] = None,
+ is_exponential_backoff_ping: bool = False,
**params: Union[None, str, datetime, int, Sequence[int], Sequence[str]],
) -> Response:
"""
@@ -282,7 +322,12 @@ class SubsonicAdapter(Adapter):
:returns: a dictionary of the subsonic response.
:raises Exception: needs some work
"""
- result = self._get(url, timeout=timeout, **params)
+ result = self._get(
+ url,
+ timeout=timeout,
+ is_exponential_backoff_ping=is_exponential_backoff_ping,
+ **params,
+ )
subsonic_response = result.json().get("subsonic-response")
# TODO (#122): make better
@@ -305,6 +350,8 @@ class SubsonicAdapter(Adapter):
def _set_mock_data(self, data: Any):
class MockResult:
+ status_code = 200
+
def __init__(self, content: Any):
self._content = content
@@ -331,7 +378,7 @@ class SubsonicAdapter(Adapter):
# ==================================================================================
def get_playlists(self) -> Sequence[API.Playlist]:
if playlists := self._get_json(self._make_url("getPlaylists")).playlists:
- return playlists.playlist
+ return sorted(playlists.playlist, key=lambda p: p.name.lower())
return []
def get_playlist_details(self, playlist_id: str) -> API.Playlist:
@@ -518,7 +565,7 @@ class SubsonicAdapter(Adapter):
def save_play_queue(
self,
- song_ids: Sequence[int],
+ song_ids: Sequence[str],
current_song_index: int = None,
position: timedelta = None,
):
diff --git a/sublime/adapters/subsonic/api_objects.py b/sublime/adapters/subsonic/api_objects.py
index e3d837b..a4d9e95 100644
--- a/sublime/adapters/subsonic/api_objects.py
+++ b/sublime/adapters/subsonic/api_objects.py
@@ -2,7 +2,6 @@
These are the API objects that are returned by Subsonic.
"""
-import hashlib
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Union
@@ -99,10 +98,6 @@ class ArtistAndArtistInfo(SublimeAPI.Artist):
music_brainz_id: Optional[str] = None
last_fm_url: Optional[str] = None
- @staticmethod
- def _strhash(string: str) -> str:
- return hashlib.sha1(bytes(string, "utf8")).hexdigest()
-
def __post_init__(self):
self.album_count = self.album_count or len(self.albums)
if not self.artist_image_url:
@@ -134,10 +129,14 @@ class ArtistInfo:
def __post_init__(self):
if self.artist_image_url:
- if self.artist_image_url.endswith("2a96cbd8b46e442fc41c2b86b821562f.png"):
- self.artist_image_url = ""
- elif self.artist_image_url.endswith("-No_image_available.svg.png"):
- self.artist_image_url = ""
+ placeholder_image_names = (
+ "2a96cbd8b46e442fc41c2b86b821562f.png",
+ "-No_image_available.svg.png",
+ )
+ for n in placeholder_image_names:
+ if self.artist_image_url.endswith(n):
+ self.artist_image_url = ""
+ return
@dataclass_json(letter_case=LetterCase.CAMEL)
diff --git a/sublime/app.py b/sublime/app.py
index 5f8a9f1..d7e0318 100644
--- a/sublime/app.py
+++ b/sublime/app.py
@@ -5,7 +5,6 @@ import sys
from datetime import timedelta
from functools import partial
from pathlib import Path
-from time import sleep
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple
try:
@@ -32,14 +31,13 @@ except Exception:
)
glib_notify_exists = False
-from .adapters import AdapterManager, AlbumSearchQuery, Result
+from .adapters import AdapterManager, AlbumSearchQuery, Result, SongCacheStatus
from .adapters.api_objects import Playlist, PlayQueue, Song
-from .config import AppConfiguration, ReplayGainType
+from .config import AppConfiguration
from .dbus import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, Player, PlayerEvent
from .ui.configure_servers import ConfigureServersDialog
from .ui.main import MainWindow
-from .ui.settings import SettingsDialog
from .ui.state import RepeatType, UIState
@@ -56,6 +54,7 @@ class SublimeMusicApp(Gtk.Application):
self.connect("shutdown", self.on_app_shutdown)
player: Player
+ exiting: bool = False
def do_startup(self):
Gtk.Application.do_startup(self)
@@ -70,7 +69,6 @@ class SublimeMusicApp(Gtk.Application):
# Add action for menu items.
add_action("configure-servers", self.on_configure_servers)
- add_action("settings", self.on_settings)
# Add actions for player controls
add_action("play-pause", self.on_play_pause)
@@ -87,6 +85,10 @@ class SublimeMusicApp(Gtk.Application):
add_action("browse-to", self.browse_to, parameter_type="s")
add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")
+ add_action("go-online", self.on_go_online)
+ add_action(
+ "refresh-window", lambda *a: self.on_refresh_window(None, {}, True),
+ )
add_action("mute-toggle", self.on_mute_toggle)
add_action(
"update-play-queue-from-server",
@@ -106,6 +108,10 @@ class SublimeMusicApp(Gtk.Application):
self.window.present()
return
+ # Configure Icons
+ icon_dir = Path(__file__).parent.joinpath("ui", "icons")
+ Gtk.IconTheme.get_default().append_search_path(str(icon_dir))
+
# Windows are associated with the application when the last one is
# closed the application shuts down.
self.window = MainWindow(application=self, title="Sublime Music")
@@ -202,13 +208,13 @@ class SublimeMusicApp(Gtk.Application):
def on_player_event(event: PlayerEvent):
if event.type == PlayerEvent.Type.PLAY_STATE_CHANGE:
- assert event.playing
+ assert event.playing is not None
self.app_config.state.playing = event.playing
if self.dbus_manager:
self.dbus_manager.property_diff()
self.update_window()
elif event.type == PlayerEvent.Type.VOLUME_CHANGE:
- assert event.volume
+ assert event.volume is not None
self.app_config.state.volume = event.volume
if self.dbus_manager:
self.dbus_manager.property_diff()
@@ -218,7 +224,7 @@ class SublimeMusicApp(Gtk.Application):
self.loading_state
or not self.window
or not self.app_config.state.current_song
- or not event.stream_cache_duration
+ or event.stream_cache_duration is None
):
return
self.app_config.state.song_stream_cache_progress = timedelta(
@@ -252,6 +258,15 @@ class SublimeMusicApp(Gtk.Application):
inital_sync_result = AdapterManager.initial_sync()
inital_sync_result.add_done_callback(lambda _: self.update_window())
+ # Start a loop for periodically updating the window every 10 seconds.
+ def periodic_update():
+ if self.exiting:
+ return
+ self.update_window()
+ GLib.timeout_add(10000, periodic_update)
+
+ GLib.timeout_add(10000, periodic_update)
+
# Prompt to load the play queue from the server.
if self.app_config.server.sync_enabled:
self.update_play_state_from_server(prompt_confirm=True)
@@ -490,6 +505,14 @@ class SublimeMusicApp(Gtk.Application):
def on_refresh_window(
self, _, state_updates: Dict[str, Any], force: bool = False,
):
+ if settings := state_updates.get("__settings__"):
+ for k, v in settings.items():
+ setattr(self.app_config, k, v)
+ if (offline_mode := settings.get("offline_mode")) is not None:
+ AdapterManager.on_offline_mode_change(offline_mode)
+
+ del state_updates["__settings__"]
+
for k, v in state_updates.items():
setattr(self.app_config.state, k, v)
self.update_window(force=force)
@@ -501,33 +524,6 @@ class SublimeMusicApp(Gtk.Application):
def on_configure_servers(self, *args):
self.show_configure_servers_dialog()
- def on_settings(self, *args):
- """Show the Settings dialog."""
- dialog = SettingsDialog(self.window, self.app_config)
- result = dialog.run()
- if result == Gtk.ResponseType.OK:
- self.app_config.port_number = int(dialog.data["port_number"].get_text())
- self.app_config.always_stream = dialog.data["always_stream"].get_active()
- self.app_config.download_on_stream = dialog.data[
- "download_on_stream"
- ].get_active()
- self.app_config.song_play_notification = dialog.data[
- "song_play_notification"
- ].get_active()
- self.app_config.serve_over_lan = dialog.data["serve_over_lan"].get_active()
- self.app_config.prefetch_amount = dialog.data[
- "prefetch_amount"
- ].get_value_as_int()
- self.app_config.concurrent_download_limit = dialog.data[
- "concurrent_download_limit"
- ].get_value_as_int()
- self.app_config.replay_gain = ReplayGainType.from_string(
- dialog.data["replay_gain"].get_active_id()
- )
- self.app_config.save()
- self.reset_state()
- dialog.destroy()
-
def on_window_go_to(self, win: Any, action: str, value: str):
{
"album": self.on_go_to_album,
@@ -540,6 +536,8 @@ class SublimeMusicApp(Gtk.Application):
if self.app_config.state.current_song_index < 0:
return
+ self.app_config.state.playing = not self.app_config.state.playing
+
if self.player.song_loaded:
self.player.toggle_play()
self.save_play_queue()
@@ -547,7 +545,6 @@ class SublimeMusicApp(Gtk.Application):
# This is from a restart, start playing the file.
self.play_song(self.app_config.state.current_song_index)
- self.app_config.state.playing = not self.app_config.state.playing
self.update_window()
def on_next_track(self, *args):
@@ -585,7 +582,12 @@ class SublimeMusicApp(Gtk.Application):
# Go back to the beginning of the song.
song_index_to_play = self.app_config.state.current_song_index
- self.play_song(song_index_to_play, reset=True)
+ self.play_song(
+ song_index_to_play,
+ reset=True,
+ # search backwards for a song to play if offline
+ playable_song_search_direction=-1,
+ )
@dbus_propagate()
def on_repeat_press(self, *args):
@@ -651,7 +653,7 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.current_tab = "albums"
self.app_config.state.selected_album_id = album_id.get_string()
- self.update_window(force=True)
+ self.update_window()
def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
self.app_config.state.current_tab = "artists"
@@ -668,6 +670,9 @@ class SublimeMusicApp(Gtk.Application):
self.app_config.state.selected_playlist_id = playlist_id.get_string()
self.update_window()
+ 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()
@@ -823,16 +828,21 @@ class SublimeMusicApp(Gtk.Application):
self.player.volume = self.app_config.state.volume
self.update_window()
- def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey,) -> bool:
+ def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey) -> bool:
# Need to use bitwise & here to see if CTRL is pressed.
if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
# Ctrl + F
window.search_entry.grab_focus()
return False
- if window.search_entry.has_focus():
+ # Allow spaces to work in the text entry boxes.
+ if (
+ window.search_entry.has_focus()
+ or window.playlists_panel.playlist_list.new_playlist_entry.has_focus()
+ ):
return False
+ # Spacebar, home/prev
keymap = {
32: self.on_play_pause,
65360: self.on_prev_track,
@@ -847,6 +857,7 @@ class SublimeMusicApp(Gtk.Application):
return False
def on_app_shutdown(self, app: "SublimeMusicApp"):
+ self.exiting = True
if glib_notify_exists:
Notify.uninit()
@@ -963,7 +974,13 @@ class SublimeMusicApp(Gtk.Application):
reset: bool = False,
old_play_queue: Tuple[str, ...] = None,
play_queue: Tuple[str, ...] = None,
+ playable_song_search_direction: int = 1,
):
+ def do_reset():
+ self.player.reset()
+ self.app_config.state.song_progress = timedelta(0)
+ self.should_scrobble_song = True
+
# Do this the old fashioned way so that we can have access to ``reset``
# in the callback.
@dbus_propagate(self)
@@ -971,16 +988,12 @@ class SublimeMusicApp(Gtk.Application):
if order_token != self.song_playing_order_token:
return
- uri = AdapterManager.get_song_filename_or_stream(
- song, force_stream=self.app_config.always_stream,
- )
+ uri = AdapterManager.get_song_filename_or_stream(song)
# Prevent it from doing the thing where it continually loads
# songs when it has to download.
if reset:
- self.player.reset()
- self.app_config.state.song_progress = timedelta(0)
- self.should_scrobble_song = True
+ do_reset()
# Start playing the song.
if order_token != self.song_playing_order_token:
@@ -1055,8 +1068,9 @@ class SublimeMusicApp(Gtk.Application):
"Unable to display notification. Is a notification daemon running?" # noqa: E501
)
- # Download current song and prefetch songs. Only do this if
- # download_on_stream is True and always_stream is off.
+ # Download current song and prefetch songs. Only do this if the adapter can
+ # download songs and allow_song_downloads is True and download_on_stream is
+ # True.
def on_song_download_complete(song_id: str):
if order_token != self.song_playing_order_token:
return
@@ -1084,8 +1098,12 @@ class SublimeMusicApp(Gtk.Application):
self.update_window()
if (
- self.app_config.download_on_stream
- and not self.app_config.always_stream
+ # This only makes sense if the adapter is networked.
+ AdapterManager.ground_truth_adapter_is_networked()
+ # Don't download in offline mode.
+ and not self.app_config.offline_mode
+ and self.app_config.allow_song_downloads
+ and self.app_config.download_on_stream
and AdapterManager.can_batch_download_songs()
):
song_ids = [song.id]
@@ -1132,26 +1150,115 @@ class SublimeMusicApp(Gtk.Application):
self.song_playing_order_token += 1
if play_queue:
+ GLib.timeout_add(
+ 5000,
+ partial(
+ self.save_play_queue,
+ song_playing_order_token=self.song_playing_order_token,
+ ),
+ )
- def save_play_queue_later(order_token: int):
- sleep(5)
- if order_token != self.song_playing_order_token:
- return
- self.save_play_queue()
+ # If in offline mode, go to the first song in the play queue after the given
+ # song that is actually playable.
+ if self.app_config.offline_mode:
+ statuses = AdapterManager.get_cached_statuses(
+ self.app_config.state.play_queue
+ )
+ playable_statuses = (
+ SongCacheStatus.CACHED,
+ SongCacheStatus.PERMANENTLY_CACHED,
+ )
+ can_play = False
+ current_song_index = self.app_config.state.current_song_index
- Result(partial(save_play_queue_later, self.song_playing_order_token))
+ if statuses[current_song_index] in playable_statuses:
+ can_play = True
+ elif self.app_config.state.repeat_type != RepeatType.REPEAT_SONG:
+ # See if any other songs in the queue are playable.
+ play_queue_len = len(self.app_config.state.play_queue)
+ cursor = (
+ current_song_index + playable_song_search_direction
+ ) % play_queue_len
+ for _ in range(play_queue_len): # Don't infinite loop.
+ if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
+ if (
+ playable_song_search_direction == 1
+ and cursor < current_song_index
+ ) or (
+ playable_song_search_direction == -1
+ and cursor > current_song_index
+ ):
+ # We wrapped around to the end of the play queue without
+ # finding a song that can be played, and we aren't allowed
+ # to loop back.
+ break
+
+ # If we find a playable song, stop and play it.
+ if statuses[cursor] in playable_statuses:
+ self.play_song(cursor, reset)
+ return
+
+ cursor = (cursor + playable_song_search_direction) % play_queue_len
+
+ if not can_play:
+ # There are no songs that can be played. Show a notification that you
+ # have to go online to play anything and then don't go further.
+ was_playing = False
+ if self.app_config.state.playing:
+ was_playing = True
+ self.on_play_pause()
+
+ def go_online_clicked():
+ self.app_config.state.current_notification = None
+ self.on_go_online()
+ if was_playing:
+ self.on_play_pause()
+
+ if all(s == SongCacheStatus.NOT_CACHED for s in statuses):
+ markup = (
+ "None of the songs in your play queue are cached for "
+ "offline playback.\nGo online to start playing your queue."
+ )
+ else:
+ markup = (
+ "None of the remaining songs in your play queue are cached "
+ "for offline playback.\nGo online to contiue playing your "
+ "queue."
+ )
+
+ self.app_config.state.current_notification = UIState.UINotification(
+ icon="cloud-offline-symbolic",
+ markup=markup,
+ actions=(("Go Online", go_online_clicked),),
+ )
+ if reset:
+ do_reset()
+ self.update_window()
+ return
song_details_future = AdapterManager.get_song_details(
self.app_config.state.play_queue[self.app_config.state.current_song_index]
)
- song_details_future.add_done_callback(
- lambda f: GLib.idle_add(
- partial(do_play_song, self.song_playing_order_token), f.result()
- ),
- )
+ if song_details_future.data_is_available:
+ song_details_future.add_done_callback(
+ lambda f: do_play_song(self.song_playing_order_token, f.result())
+ )
+ else:
+ song_details_future.add_done_callback(
+ lambda f: GLib.idle_add(
+ partial(do_play_song, self.song_playing_order_token), f.result()
+ ),
+ )
- def save_play_queue(self):
- if len(self.app_config.state.play_queue) == 0:
+ 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 (
+ song_playing_order_token
+ and song_playing_order_token != self.song_playing_order_token
+ )
+ ):
return
position = self.app_config.state.song_progress
diff --git a/sublime/config.py b/sublime/config.py
index 8eb1868..af0d926 100644
--- a/sublime/config.py
+++ b/sublime/config.py
@@ -72,29 +72,39 @@ class ServerConfiguration:
@dataclass
class AppConfiguration:
+ version: int = 3
+ cache_location: str = ""
+ filename: Optional[Path] = None
+
+ # Servers
servers: List[ServerConfiguration] = field(default_factory=list)
current_server_index: int = -1
- cache_location: str = ""
- max_cache_size_mb: int = -1 # -1 means unlimited
- always_stream: bool = False # always stream instead of downloading songs
- download_on_stream: bool = True # also download when streaming a song
+
+ # Global Settings
song_play_notification: bool = True
+ offline_mode: bool = False
+ serve_over_lan: bool = True
+ port_number: int = 8282
+ replay_gain: ReplayGainType = ReplayGainType.NO
+ allow_song_downloads: bool = True
+ download_on_stream: bool = True # also download when streaming a song
prefetch_amount: int = 3
concurrent_download_limit: int = 5
- port_number: int = 8282
- version: int = 3
- serve_over_lan: bool = True
- replay_gain: ReplayGainType = ReplayGainType.NO
- filename: Optional[Path] = None
+
+ # Deprecated
+ always_stream: bool = False # always stream instead of downloading songs
@staticmethod
def load_from_file(filename: Path) -> "AppConfiguration":
args = {}
- 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))
+ 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))
+ except Exception:
+ pass
config = AppConfiguration(**args)
config.filename = filename
@@ -114,11 +124,16 @@ class AppConfiguration:
self._state = None
self._current_server_hash = None
+ self.migrate()
def migrate(self):
for server in self.servers:
server.migrate()
- self.version = 3
+
+ if self.version < 4:
+ self.allow_song_downloads = not self.always_stream
+
+ self.version = 4
self.state.migrate()
@property
diff --git a/sublime/players.py b/sublime/players.py
index eb3d8a8..9c3a396 100644
--- a/sublime/players.py
+++ b/sublime/players.py
@@ -30,6 +30,8 @@ class PlayerEvent:
PLAY_STATE_CHANGE = 0
VOLUME_CHANGE = 1
STREAM_CACHE_PROGRESS_CHANGE = 2
+ CONNECTING = 3
+ CONNECTED = 4
type: Type
playing: Optional[bool] = False
@@ -320,6 +322,7 @@ class ChromecastPlayer(Player):
return ChromecastPlayer.executor.submit(do_get_chromecasts)
def set_playing_chromecast(self, uuid: str):
+ self.on_player_event(PlayerEvent(PlayerEvent.Type.CONNECTING))
self.chromecast = next(
cc for cc in ChromecastPlayer.chromecasts if cc.device.uuid == UUID(uuid)
)
@@ -329,7 +332,8 @@ class ChromecastPlayer(Player):
)
self.chromecast.register_status_listener(ChromecastPlayer.cast_status_listener)
self.chromecast.wait()
- logging.info(f"Using: {self.chromecast.device.friendly_name}")
+ logging.info(f"Connected to Chromecast: {self.chromecast.device.friendly_name}")
+ self.on_player_event(PlayerEvent(PlayerEvent.Type.CONNECTED))
def __init__(
self,
@@ -455,7 +459,7 @@ class ChromecastPlayer(Player):
# If it's a local file, then see if we can serve it over the LAN.
if not stream_scheme:
if self.serve_over_lan:
- token = base64.b64encode(os.urandom(64)).decode("ascii")
+ token = base64.b64encode(os.urandom(8)).decode("ascii")
for r in (("+", "."), ("/", "-"), ("=", "_")):
token = token.replace(*r)
self.server_thread.set_song_and_token(song.id, token)
diff --git a/sublime/ui/albums.py b/sublime/ui/albums.py
index 4a9c653..05107ca 100644
--- a/sublime/ui/albums.py
+++ b/sublime/ui/albums.py
@@ -10,11 +10,12 @@ from sublime.adapters import (
AdapterManager,
AlbumSearchQuery,
api_objects as API,
+ CacheMissError,
Result,
)
from sublime.config import AppConfiguration
from sublime.ui import util
-from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage
+from sublime.ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
def _to_type(query_type: AlbumSearchQuery.Type) -> str:
@@ -59,6 +60,7 @@ class AlbumsPanel(Gtk.Box):
),
}
+ offline_mode = False
populating_genre_combo = False
grid_order_token: int = 0
album_sort_direction: str = "ascending"
@@ -143,11 +145,11 @@ class AlbumsPanel(Gtk.Box):
page_widget.add(self.next_page)
actionbar.set_center_widget(page_widget)
- refresh = IconButton(
+ self.refresh_button = IconButton(
"view-refresh-symbolic", "Refresh list of albums", relief=True
)
- refresh.connect("clicked", self.on_refresh_clicked)
- actionbar.pack_end(refresh)
+ self.refresh_button.connect("clicked", self.on_refresh_clicked)
+ actionbar.pack_end(self.refresh_button)
actionbar.pack_end(Gtk.Label(label="albums per page"))
self.show_count_dropdown, _ = self.make_combobox(
@@ -233,6 +235,7 @@ class AlbumsPanel(Gtk.Box):
if app_config:
self.current_query = app_config.state.current_album_search_query
+ self.offline_mode = app_config.offline_mode
self.alphabetical_type_combo.set_active_id(
{
@@ -251,6 +254,7 @@ class AlbumsPanel(Gtk.Box):
if app_config:
self.album_page = app_config.state.album_page
self.album_page_size = app_config.state.album_page_size
+ self.refresh_button.set_sensitive(not app_config.offline_mode)
self.prev_page.set_sensitive(self.album_page > 0)
self.page_entry.set_text(str(self.album_page + 1))
@@ -300,7 +304,9 @@ class AlbumsPanel(Gtk.Box):
self.populate_genre_combo(app_config, force=force)
# At this point, the current query should be totally updated.
- self.grid_order_token = self.grid.update_params(self.current_query)
+ self.grid_order_token = self.grid.update_params(
+ self.current_query, self.offline_mode
+ )
self.grid.update(self.grid_order_token, app_config, force=force)
def _get_opposite_sort_dir(self, sort_dir: str) -> str:
@@ -399,7 +405,7 @@ class AlbumsPanel(Gtk.Box):
if self.to_year_spin_button == entry:
new_year_tuple = (self.current_query.year_range[0], year)
else:
- new_year_tuple = (year, self.current_query.year_range[0])
+ new_year_tuple = (year, self.current_query.year_range[1])
self.emit_if_not_updating(
"refresh-window",
@@ -504,6 +510,7 @@ class AlbumsGrid(Gtk.Overlay):
current_models: List[_AlbumModel] = []
latest_applied_order_ratchet: int = 0
order_ratchet: int = 0
+ offline_mode: bool = False
currently_selected_index: Optional[int] = None
currently_selected_id: Optional[str] = None
@@ -514,11 +521,14 @@ class AlbumsGrid(Gtk.Overlay):
next_page_fn = None
server_hash: Optional[str] = None
- def update_params(self, query: AlbumSearchQuery) -> int:
+ def update_params(self, query: AlbumSearchQuery, offline_mode: bool) -> int:
# If there's a diff, increase the ratchet.
if self.current_query.strhash() != query.strhash():
self.order_ratchet += 1
self.current_query = query
+ if offline_mode != self.offline_mode:
+ self.order_ratchet += 1
+ self.offline_mode = offline_mode
return self.order_ratchet
def __init__(self, *args, **kwargs):
@@ -529,6 +539,9 @@ class AlbumsGrid(Gtk.Overlay):
scrolled_window = Gtk.ScrolledWindow()
grid_detail_grid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ self.error_container = Gtk.Box()
+ grid_detail_grid_box.add(self.error_container)
+
def create_flowbox(**kwargs) -> Gtk.FlowBox:
flowbox = Gtk.FlowBox(
**kwargs,
@@ -541,7 +554,7 @@ class AlbumsGrid(Gtk.Overlay):
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.SINGLE,
)
- flowbox.set_max_children_per_line(10)
+ flowbox.set_max_children_per_line(7)
return flowbox
self.grid_top = create_flowbox()
@@ -554,7 +567,9 @@ class AlbumsGrid(Gtk.Overlay):
grid_detail_grid_box.add(self.grid_top)
self.detail_box_revealer = Gtk.Revealer(valign=Gtk.Align.END)
- self.detail_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ self.detail_box = Gtk.Box(
+ orientation=Gtk.Orientation.HORIZONTAL, name="artist-detail-box"
+ )
self.detail_box.pack_start(Gtk.Box(), True, True, 0)
self.detail_box_inner = Gtk.Box()
@@ -619,7 +634,7 @@ class AlbumsGrid(Gtk.Overlay):
# Update the detail panel.
children = self.detail_box_inner.get_children()
if len(children) > 0 and hasattr(children[0], "update"):
- children[0].update(force=force)
+ children[0].update(app_config=app_config, force=force)
error_dialog = None
@@ -643,11 +658,9 @@ class AlbumsGrid(Gtk.Overlay):
if self.sort_dir == "descending" and selected_index:
selected_index = len(self.current_models) - selected_index - 1
- selection_changed = selected_index != self.currently_selected_index
- self.currently_selected_index = selected_index
self.reflow_grids(
force_reload_from_master=force_grid_reload_from_master,
- selection_changed=selection_changed,
+ selected_index=selected_index,
models=self.current_models,
)
self.spinner.hide()
@@ -658,8 +671,12 @@ class AlbumsGrid(Gtk.Overlay):
return
self.latest_applied_order_ratchet = self.order_ratchet
+ is_partial = False
try:
- albums = f.result()
+ albums = list(f.result())
+ except CacheMissError as e:
+ albums = cast(Optional[List[API.Album]], e.partial_data) or []
+ is_partial = True
except Exception as e:
if self.error_dialog:
self.spinner.hide()
@@ -682,6 +699,23 @@ class AlbumsGrid(Gtk.Overlay):
self.spinner.hide()
return
+ for c in self.error_container.get_children():
+ self.error_container.remove(c)
+ if is_partial and (
+ len(albums) == 0
+ or self.current_query.type != AlbumSearchQuery.Type.RANDOM
+ ):
+ load_error = LoadError(
+ "Album list",
+ "load albums",
+ has_data=albums is not None and len(albums) > 0,
+ offline_mode=self.offline_mode,
+ )
+ self.error_container.pack_start(load_error, True, True, 0)
+ self.error_container.show_all()
+ else:
+ self.error_container.hide()
+
selected_index = None
self.current_models = []
for i, album in enumerate(albums):
@@ -746,14 +780,17 @@ class AlbumsGrid(Gtk.Overlay):
# add extra padding.
# 200 + (10 * 2) + (5 * 2) = 230
# picture + (padding * 2) + (margin * 2)
- new_items_per_row = min((rect.width // 230), 10)
+ new_items_per_row = min((rect.width // 230), 7)
if new_items_per_row != self.items_per_row:
self.items_per_row = new_items_per_row
self.detail_box_inner.set_size_request(
self.items_per_row * 230 - 10, -1,
)
- self.reflow_grids(force_reload_from_master=True)
+ self.reflow_grids(
+ force_reload_from_master=True,
+ selected_index=self.currently_selected_index,
+ )
# Helper Methods
# =========================================================================
@@ -763,7 +800,7 @@ class AlbumsGrid(Gtk.Overlay):
label=text,
tooltip_text=text,
ellipsize=Pango.EllipsizeMode.END,
- max_width_chars=20,
+ max_width_chars=22,
halign=Gtk.Align.START,
)
@@ -813,17 +850,18 @@ class AlbumsGrid(Gtk.Overlay):
def reflow_grids(
self,
force_reload_from_master: bool = False,
- selection_changed: bool = False,
+ selected_index: int = None,
models: List[_AlbumModel] = None,
):
# Calculate the page that the currently_selected_index is in. If it's a
# different page, then update the window.
- page_changed = False
- if self.currently_selected_index is not None:
- page_of_selected_index = self.currently_selected_index // self.page_size
+ if selected_index is not None:
+ page_of_selected_index = selected_index // self.page_size
if page_of_selected_index != self.page:
- page_changed = True
- self.page = page_of_selected_index
+ self.emit(
+ "refresh-window", {"album_page": page_of_selected_index}, False
+ )
+ return
page_offset = self.page_size * self.page
# Calculate the look-at window.
@@ -841,14 +879,14 @@ class AlbumsGrid(Gtk.Overlay):
# Determine where the cuttoff is between the top and bottom grids.
entries_before_fold = self.page_size
- if self.currently_selected_index is not None and self.items_per_row:
- relative_selected_index = self.currently_selected_index - page_offset
+ if selected_index is not None and self.items_per_row:
+ relative_selected_index = selected_index - page_offset
entries_before_fold = (
(relative_selected_index // self.items_per_row) + 1
) * self.items_per_row
# Unreveal the current album details first
- if self.currently_selected_index is None:
+ if selected_index is None:
self.detail_box_revealer.set_reveal_child(False)
if force_reload_from_master:
@@ -860,7 +898,7 @@ class AlbumsGrid(Gtk.Overlay):
self.list_store_bottom.splice(
0, len(self.list_store_bottom), window[entries_before_fold:],
)
- elif self.currently_selected_index or entries_before_fold != self.page_size:
+ elif selected_index or entries_before_fold != self.page_size:
# This case handles when the selection changes and the entries need to be
# re-allocated to the top and bottom grids
# Move entries between the two stores.
@@ -880,14 +918,14 @@ class AlbumsGrid(Gtk.Overlay):
self.list_store_bottom.splice(0, 0, self.list_store_top[-diff:])
self.list_store_top.splice(top_store_len - diff, diff, [])
- if self.currently_selected_index is not None:
- relative_selected_index = self.currently_selected_index - page_offset
+ if selected_index is not None:
+ relative_selected_index = selected_index - page_offset
to_select = self.grid_top.get_child_at_index(relative_selected_index)
if not to_select:
return
self.grid_top.select_child(to_select)
- if not selection_changed:
+ if self.currently_selected_index == selected_index:
return
for c in self.detail_box_inner.get_children():
@@ -911,9 +949,4 @@ class AlbumsGrid(Gtk.Overlay):
self.grid_top.unselect_all()
self.grid_bottom.unselect_all()
- # If we had to change the page to select the index, then update the window. It
- # should basically be a no-op.
- if page_changed:
- self.emit(
- "refresh-window", {"album_page": self.page}, False,
- )
+ self.currently_selected_index = selected_index
diff --git a/sublime/ui/app_styles.css b/sublime/ui/app_styles.css
index 3403203..133030d 100644
--- a/sublime/ui/app_styles.css
+++ b/sublime/ui/app_styles.css
@@ -1,7 +1,38 @@
/* ********** Main ********** */
#connected-to-label {
- margin-top: 10px;
- margin-bottom: 10px;
+ margin: 5px 15px;
+ font-size: 1.2em;
+}
+
+#connected-status-row {
+ margin-bottom: 5px;
+}
+
+#online-status-icon {
+ margin-right: 10px;
+}
+
+#menu-header {
+ margin: 10px 15px 10px 5px;
+ font-weight: bold;
+}
+
+#menu-settings-separator {
+ margin-bottom: 5px;
+ font-weight: bold;
+}
+
+#current-downloads-list {
+ min-height: 30px;
+ min-width: 250px;
+}
+
+.menu-label {
+ margin-right: 15px;
+}
+
+#main-menu-box {
+ min-width: 230px;
}
#icon-button-box image {
@@ -14,6 +45,11 @@
margin-right: 3px;
}
+#menu-item-download-settings,
+#menu-item-clear-cache {
+ min-width: 230px;
+}
+
/* ********** Playlist ********** */
#playlist-list-listbox row {
margin: 0;
@@ -61,16 +97,9 @@
}
#playlist-album-artwork {
- min-height: 200px;
- min-width: 200px;
margin: 10px 15px 0 10px;
}
-#playlist-album-artwork.collapsed {
- min-height: 70px;
- min-width: 70px;
-}
-
#playlist-name, #artist-detail-panel #artist-name {
font-size: 40px;
margin-bottom: 10px;
@@ -152,6 +181,10 @@
min-width: 50px;
}
+#play-queue-image-disabled {
+ opacity: 0.5;
+}
+
/* ********** General ********** */
.menu-button {
padding: 5px;
@@ -181,6 +214,17 @@
min-height: 30px;
}
+/* ********** Error Indicator ********** */
+#load-error-box {
+ margin: 15px;
+}
+
+#load-error-image,
+#load-error-label {
+ margin-bottom: 5px;
+ margin-right: 20px;
+}
+
/* ********** Artists & Albums ********** */
#grid-artwork-spinner, #album-list-song-list-spinner {
min-height: 35px;
@@ -213,16 +257,9 @@
}
#artist-album-artwork {
- min-width: 300px;
- min-height: 300px;
margin: 10px 15px 0 10px;
}
-#artist-album-artwork.collapsed {
- min-width: 70px;
- min-height: 70px;
-}
-
#artist-album-list-artwork {
margin: 10px;
}
@@ -236,6 +273,18 @@
margin: 15px;
}
+@define-color box_shadow_color rgba(0, 0, 0, 0.2);
+
#artist-info-panel {
+ box-shadow: 0 5px 5px @box_shadow_color;
margin-bottom: 10px;
+ padding-bottom: 10px;
+}
+
+#artist-detail-box {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ box-shadow: inset 0 5px 5px @box_shadow_color,
+ inset 0 -5px 5px @box_shadow_color;
+ background-color: @box_shadow_color;
}
diff --git a/sublime/ui/artists.py b/sublime/ui/artists.py
index 4c7e660..46cbef4 100644
--- a/sublime/ui/artists.py
+++ b/sublime/ui/artists.py
@@ -1,13 +1,18 @@
from datetime import timedelta
from random import randint
-from typing import List, Sequence
+from typing import cast, List, Sequence
from gi.repository import Gio, GLib, GObject, Gtk, Pango
-from sublime.adapters import AdapterManager, api_objects as API
+from sublime.adapters import (
+ AdapterManager,
+ api_objects as API,
+ CacheMissError,
+ SongCacheStatus,
+)
from sublime.config import AppConfiguration
from sublime.ui import util
-from sublime.ui.common import AlbumWithSongs, IconButton, SpinnerImage
+from sublime.ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage
class ArtistsPanel(Gtk.Paned):
@@ -64,12 +69,17 @@ class ArtistList(Gtk.Box):
list_actions = Gtk.ActionBar()
- refresh = IconButton("view-refresh-symbolic", "Refresh list of artists")
- refresh.connect("clicked", lambda *a: self.update(force=True))
- list_actions.pack_end(refresh)
+ self.refresh_button = IconButton(
+ "view-refresh-symbolic", "Refresh list of artists"
+ )
+ self.refresh_button.connect("clicked", lambda *a: self.update(force=True))
+ list_actions.pack_end(self.refresh_button)
self.add(list_actions)
+ self.error_container = Gtk.Box()
+ self.add(self.error_container)
+
self.loading_indicator = Gtk.ListBox()
spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False)
spinner = Gtk.Spinner(name="artist-list-spinner", active=True)
@@ -122,10 +132,28 @@ class ArtistList(Gtk.Box):
self,
artists: Sequence[API.Artist],
app_config: AppConfiguration = None,
+ is_partial: bool = False,
**kwargs,
):
if app_config:
self._app_config = app_config
+ self.refresh_button.set_sensitive(not app_config.offline_mode)
+
+ for c in self.error_container.get_children():
+ self.error_container.remove(c)
+ if is_partial:
+ load_error = LoadError(
+ "Artist list",
+ "load artists",
+ has_data=len(artists) > 0,
+ offline_mode=(
+ self._app_config.offline_mode if self._app_config else False
+ ),
+ )
+ self.error_container.pack_start(load_error, True, True, 0)
+ self.error_container.show_all()
+ else:
+ self.error_container.hide()
new_store = []
selected_idx = None
@@ -229,17 +257,17 @@ class ArtistDetailPanel(Gtk.Box):
name="playlist-play-shuffle-buttons",
)
- play_button = IconButton(
- "media-playback-start-symbolic", label="Play All", relief=True,
+ self.play_button = IconButton(
+ "media-playback-start-symbolic", label="Play All", relief=True
)
- play_button.connect("clicked", self.on_play_all_clicked)
- self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
+ self.play_button.connect("clicked", self.on_play_all_clicked)
+ self.play_shuffle_buttons.pack_start(self.play_button, False, False, 0)
- shuffle_button = IconButton(
- "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True,
+ self.shuffle_button = IconButton(
+ "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True
)
- shuffle_button.connect("clicked", self.on_shuffle_all_button)
- self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
+ self.shuffle_button.connect("clicked", self.on_shuffle_all_button)
+ self.play_shuffle_buttons.pack_start(self.shuffle_button, False, False, 5)
artist_details_box.add(self.play_shuffle_buttons)
self.big_info_panel.pack_start(artist_details_box, True, True, 0)
@@ -250,15 +278,15 @@ class ArtistDetailPanel(Gtk.Box):
orientation=Gtk.Orientation.HORIZONTAL, spacing=10
)
- download_all_btn = IconButton(
+ self.download_all_button = IconButton(
"folder-download-symbolic", "Download all songs by this artist"
)
- download_all_btn.connect("clicked", self.on_download_all_click)
- self.artist_action_buttons.add(download_all_btn)
+ self.download_all_button.connect("clicked", self.on_download_all_click)
+ self.artist_action_buttons.add(self.download_all_button)
- view_refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
- view_refresh_button.connect("clicked", self.on_view_refresh_click)
- self.artist_action_buttons.add(view_refresh_button)
+ self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info")
+ self.refresh_button.connect("clicked", self.on_view_refresh_click)
+ self.artist_action_buttons.add(self.refresh_button)
action_buttons_container.pack_start(
self.artist_action_buttons, False, False, 10
@@ -278,6 +306,9 @@ class ArtistDetailPanel(Gtk.Box):
self.pack_start(self.big_info_panel, False, True, 0)
+ self.error_container = Gtk.Box()
+ self.add(self.error_container)
+
self.album_list_scrolledwindow = Gtk.ScrolledWindow()
self.albums_list = AlbumsListWithSongs()
self.albums_list.connect(
@@ -288,6 +319,7 @@ class ArtistDetailPanel(Gtk.Box):
def update(self, app_config: AppConfiguration):
self.artist_id = app_config.state.selected_artist_id
+ self.offline_mode = app_config.offline_mode
if app_config.state.selected_artist_id is None:
self.big_info_panel.hide()
self.album_list_scrolledwindow.hide()
@@ -300,6 +332,8 @@ class ArtistDetailPanel(Gtk.Box):
app_config=app_config,
order_token=self.update_order_token,
)
+ self.refresh_button.set_sensitive(not self.offline_mode)
+ self.download_all_button.set_sensitive(not self.offline_mode)
@util.async_callback(
AdapterManager.get_artist,
@@ -312,11 +346,12 @@ class ArtistDetailPanel(Gtk.Box):
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
+ is_partial: bool = False,
):
if order_token != self.update_order_token:
return
- self.big_info_panel.show()
+ self.big_info_panel.show_all()
if app_config:
self.artist_details_expanded = app_config.state.artist_details_expanded
@@ -333,11 +368,14 @@ class ArtistDetailPanel(Gtk.Box):
if self.artist_details_expanded:
self.artist_artwork.get_style_context().remove_class("collapsed")
self.artist_name.get_style_context().remove_class("collapsed")
- self.artist_artwork.set_image_size(300)
self.artist_indicator.set_text("ARTIST")
self.artist_stats.set_markup(self.format_stats(artist))
- self.artist_bio.set_markup(util.esc(artist.biography))
+ if artist.biography:
+ self.artist_bio.set_markup(util.esc(artist.biography))
+ self.artist_bio.show()
+ else:
+ self.artist_bio.hide()
if len(artist.similar_artists or []) > 0:
self.similar_artists_label.set_markup("Similar Artists: ")
@@ -359,7 +397,6 @@ class ArtistDetailPanel(Gtk.Box):
else:
self.artist_artwork.get_style_context().add_class("collapsed")
self.artist_name.get_style_context().add_class("collapsed")
- self.artist_artwork.set_image_size(70)
self.artist_indicator.hide()
self.artist_stats.hide()
self.artist_bio.hide()
@@ -371,8 +408,56 @@ class ArtistDetailPanel(Gtk.Box):
artist.artist_image_url, force=force, order_token=order_token,
)
+ for c in self.error_container.get_children():
+ self.error_container.remove(c)
+ if is_partial:
+ has_data = len(artist.albums or []) > 0
+ load_error = LoadError(
+ "Artist data",
+ "load artist details",
+ has_data=has_data,
+ offline_mode=self.offline_mode,
+ )
+ self.error_container.pack_start(load_error, True, True, 0)
+ self.error_container.show_all()
+ if not has_data:
+ self.album_list_scrolledwindow.hide()
+ else:
+ self.error_container.hide()
+ self.album_list_scrolledwindow.show()
+
self.albums = artist.albums or []
- self.albums_list.update(artist)
+
+ # (Dis|En)able the "Play All" and "Shuffle All" buttons. If in offline mode, it
+ # depends on whether or not there are any cached songs.
+ if self.offline_mode:
+ has_cached_song = False
+ playable_statuses = (
+ SongCacheStatus.CACHED,
+ SongCacheStatus.PERMANENTLY_CACHED,
+ )
+
+ for album in self.albums:
+ if album.id:
+ try:
+ songs = AdapterManager.get_album(album.id).result().songs or []
+ except CacheMissError as e:
+ if e.partial_data:
+ songs = cast(API.Album, e.partial_data).songs or []
+ else:
+ songs = []
+ statuses = AdapterManager.get_cached_statuses([s.id for s in songs])
+ if any(s in playable_statuses for s in statuses):
+ has_cached_song = True
+ break
+
+ self.play_button.set_sensitive(has_cached_song)
+ self.shuffle_button.set_sensitive(has_cached_song)
+ else:
+ self.play_button.set_sensitive(not self.offline_mode)
+ self.shuffle_button.set_sensitive(not self.offline_mode)
+
+ self.albums_list.update(artist, app_config, force=force)
@util.async_callback(
AdapterManager.get_cover_art_filename,
@@ -385,12 +470,18 @@ class ArtistDetailPanel(Gtk.Box):
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
+ is_partial: bool = False,
):
if order_token != self.update_order_token:
return
self.artist_artwork.set_from_file(cover_art_filename)
self.artist_artwork.set_loading(False)
+ if self.artist_details_expanded:
+ self.artist_artwork.set_image_size(300)
+ else:
+ self.artist_artwork.set_image_size(70)
+
# Event Handlers
# =========================================================================
def on_view_refresh_click(self, *args):
@@ -442,7 +533,7 @@ class ArtistDetailPanel(Gtk.Box):
self.albums_list.spinner.hide()
self.artist_artwork.set_loading(False)
- def make_label(self, text: str = None, name: str = None, **params,) -> Gtk.Label:
+ def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label:
return Gtk.Label(
label=text, name=name, halign=Gtk.Align.START, xalign=0, **params,
)
@@ -461,11 +552,24 @@ class ArtistDetailPanel(Gtk.Box):
)
def get_artist_song_ids(self) -> List[str]:
+ try:
+ artist = AdapterManager.get_artist(self.artist_id).result()
+ except CacheMissError as c:
+ artist = cast(API.Artist, c.partial_data)
+
+ if not artist:
+ return []
+
songs = []
- for album in AdapterManager.get_artist(self.artist_id).result().albums or []:
+ for album in artist.albums or []:
assert album.id
- album_songs = AdapterManager.get_album(album.id).result()
- for song in album_songs.songs or []:
+ try:
+ album_with_songs = AdapterManager.get_album(album.id).result()
+ except CacheMissError as c:
+ album_with_songs = cast(API.Album, c.partial_data)
+ if not album_with_songs:
+ continue
+ for song in album_with_songs.songs or []:
songs.append(song.id)
return songs
@@ -495,7 +599,9 @@ class AlbumsListWithSongs(Gtk.Overlay):
self.albums = []
- def update(self, artist: API.Artist):
+ def update(
+ self, artist: API.Artist, app_config: AppConfiguration, force: bool = False
+ ):
def remove_all():
for c in self.box.get_children():
self.box.remove(c)
@@ -510,7 +616,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
if self.albums == new_albums:
# Just go through all of the colidren and update them.
for c in self.box.get_children():
- c.update()
+ c.update(app_config=app_config, force=force)
self.spinner.hide()
return
@@ -528,7 +634,11 @@ class AlbumsListWithSongs(Gtk.Overlay):
album_with_songs.show_all()
self.box.add(album_with_songs)
- self.spinner.stop()
+ # Update everything (no force to ensure that if we are online, then everything
+ # is clickable)
+ for c in self.box.get_children():
+ c.update(app_config=app_config)
+
self.spinner.hide()
def on_song_selected(self, album_component: AlbumWithSongs):
diff --git a/sublime/ui/browse.py b/sublime/ui/browse.py
index 9999c7f..f618a4a 100644
--- a/sublime/ui/browse.py
+++ b/sublime/ui/browse.py
@@ -3,10 +3,10 @@ from typing import Any, cast, List, Optional, Tuple
from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
-from sublime.adapters import AdapterManager, api_objects as API, Result
+from sublime.adapters import AdapterManager, api_objects as API, CacheMissError, Result
from sublime.config import AppConfiguration
from sublime.ui import util
-from sublime.ui.common import IconButton, SongListColumn
+from sublime.ui.common import IconButton, LoadError, SongListColumn
class BrowsePanel(Gtk.Overlay):
@@ -30,6 +30,10 @@ class BrowsePanel(Gtk.Overlay):
def __init__(self):
super().__init__()
scrolled_window = Gtk.ScrolledWindow()
+ window_box = Gtk.Box()
+
+ self.error_container = Gtk.Box()
+ window_box.pack_start(self.error_container, True, True, 0)
self.root_directory_listing = ListAndDrilldown()
self.root_directory_listing.connect(
@@ -38,8 +42,9 @@ class BrowsePanel(Gtk.Overlay):
self.root_directory_listing.connect(
"refresh-window", lambda _, *args: self.emit("refresh-window", *args),
)
- scrolled_window.add(self.root_directory_listing)
+ window_box.add(self.root_directory_listing)
+ scrolled_window.add(window_box)
self.add(scrolled_window)
self.spinner = Gtk.Spinner(
@@ -51,16 +56,28 @@ class BrowsePanel(Gtk.Overlay):
self.add_overlay(self.spinner)
def update(self, app_config: AppConfiguration, force: bool = False):
- if not AdapterManager.can_get_directory():
- return
-
self.update_order_token += 1
def do_update(update_order_token: int, id_stack: Tuple[str, ...]):
if self.update_order_token != update_order_token:
return
- self.root_directory_listing.update(id_stack, app_config, force)
+ if len(id_stack) == 0:
+ self.root_directory_listing.hide()
+ if len(self.error_container.get_children()) == 0:
+ load_error = LoadError(
+ "Directory list",
+ "browse to song",
+ has_data=False,
+ offline_mode=app_config.offline_mode,
+ )
+ self.error_container.pack_start(load_error, True, True, 0)
+ self.error_container.show_all()
+ else:
+ for c in self.error_container.get_children():
+ self.error_container.remove(c)
+ self.error_container.hide()
+ self.root_directory_listing.update(id_stack, app_config, force)
self.spinner.hide()
def calculate_path() -> Tuple[str, ...]:
@@ -68,13 +85,19 @@ class BrowsePanel(Gtk.Overlay):
return ("root",)
id_stack = []
- while current_dir_id and (
- directory := AdapterManager.get_directory(
- current_dir_id, before_download=self.spinner.show,
- ).result()
- ):
- id_stack.append(directory.id)
- current_dir_id = directory.parent_id
+ while current_dir_id:
+ try:
+ directory = AdapterManager.get_directory(
+ current_dir_id, before_download=self.spinner.show,
+ ).result()
+ except CacheMissError as e:
+ directory = cast(API.Directory, e.partial_data)
+
+ if not directory:
+ break
+ else:
+ id_stack.append(directory.id)
+ current_dir_id = directory.parent_id
return tuple(id_stack)
@@ -123,6 +146,7 @@ class ListAndDrilldown(Gtk.Paned):
):
*child_id_stack, dir_id = id_stack
selected_id = child_id_stack[-1] if len(child_id_stack) > 0 else None
+ self.show()
self.list.update(
directory_id=dir_id,
@@ -170,6 +194,7 @@ class MusicDirectoryList(Gtk.Box):
update_order_token = 0
directory_id: Optional[str] = None
selected_id: Optional[str] = None
+ offline_mode = False
class DrilldownElement(GObject.GObject):
id = GObject.Property(type=str)
@@ -185,9 +210,9 @@ class MusicDirectoryList(Gtk.Box):
list_actions = Gtk.ActionBar()
- refresh = IconButton("view-refresh-symbolic", "Refresh folder")
- refresh.connect("clicked", lambda *a: self.update(force=True))
- list_actions.pack_end(refresh)
+ self.refresh_button = IconButton("view-refresh-symbolic", "Refresh folder")
+ self.refresh_button.connect("clicked", lambda *a: self.update(force=True))
+ list_actions.pack_end(self.refresh_button)
self.add(list_actions)
@@ -198,6 +223,9 @@ class MusicDirectoryList(Gtk.Box):
self.loading_indicator.add(spinner_row)
self.pack_start(self.loading_indicator, False, False, 0)
+ self.error_container = Gtk.Box()
+ self.add(self.error_container)
+
self.scroll_window = Gtk.ScrolledWindow(min_content_width=250)
scrollbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
@@ -206,27 +234,28 @@ class MusicDirectoryList(Gtk.Box):
self.list.bind_model(self.drilldown_directories_store, self.create_row)
scrollbox.add(self.list)
- self.directory_song_store = Gtk.ListStore(
- str, str, str, str, # cache status, title, duration, song ID
- )
+ # clickable, cache status, title, duration, song ID
+ self.directory_song_store = Gtk.ListStore(bool, str, str, str, str)
self.directory_song_list = Gtk.TreeView(
model=self.directory_song_store,
name="directory-songs-list",
headers_visible=False,
)
- self.directory_song_list.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
+ selection = self.directory_song_list.get_selection()
+ selection.set_mode(Gtk.SelectionMode.MULTIPLE)
+ selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
# Song status column.
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35)
- column = Gtk.TreeViewColumn("", renderer, icon_name=0)
+ column = Gtk.TreeViewColumn("", renderer, icon_name=1)
column.set_resizable(True)
self.directory_song_list.append_column(column)
- self.directory_song_list.append_column(SongListColumn("TITLE", 1, bold=True))
+ self.directory_song_list.append_column(SongListColumn("TITLE", 2, bold=True))
self.directory_song_list.append_column(
- SongListColumn("DURATION", 2, align=1, width=40)
+ SongListColumn("DURATION", 3, align=1, width=40)
)
self.directory_song_list.connect("row-activated", self.on_song_activated)
@@ -251,6 +280,17 @@ class MusicDirectoryList(Gtk.Box):
self.directory_id, force=force, order_token=self.update_order_token,
)
+ if app_config:
+ # Deselect everything if switching online to offline.
+ if self.offline_mode != app_config.offline_mode:
+ self.directory_song_list.get_selection().unselect_all()
+ for c in self.error_container.get_children():
+ self.error_container.remove(c)
+
+ self.offline_mode = app_config.offline_mode
+
+ self.refresh_button.set_sensitive(not self.offline_mode)
+
_current_child_ids: List[str] = []
@util.async_callback(
@@ -264,10 +304,26 @@ class MusicDirectoryList(Gtk.Box):
app_config: AppConfiguration = None,
force: bool = False,
order_token: int = None,
+ is_partial: bool = False,
):
if order_token != self.update_order_token:
return
+ dir_children = directory.children or []
+ for c in self.error_container.get_children():
+ self.error_container.remove(c)
+ if is_partial:
+ load_error = LoadError(
+ "Directory listing",
+ "load directory",
+ has_data=len(dir_children) > 0,
+ offline_mode=self.offline_mode,
+ )
+ self.error_container.pack_start(load_error, True, True, 0)
+ self.error_container.show_all()
+ else:
+ self.error_container.hide()
+
# This doesn't look efficient, since it's doing a ton of passses over the data,
# but there is some annoying memory overhead for generating the stores to diff,
# so we are short-circuiting by checking to see if any of the the IDs have
@@ -278,7 +334,10 @@ class MusicDirectoryList(Gtk.Box):
# changed.
children_ids, children, song_ids = [], [], []
selected_dir_idx = None
- for i, c in enumerate(directory.children):
+ if len(self._current_child_ids) != len(dir_children):
+ force = True
+
+ for i, c in enumerate(dir_children):
if i >= len(self._current_child_ids) or c.id != self._current_child_ids[i]:
force = True
@@ -310,6 +369,11 @@ class MusicDirectoryList(Gtk.Box):
new_songs_store = [
[
+ (
+ not self.offline_mode
+ or status_icon
+ in ("folder-download-symbolic", "view-pin-symbolic")
+ ),
status_icon,
util.esc(song.title),
util.format_song_duration(song.duration),
@@ -321,13 +385,22 @@ class MusicDirectoryList(Gtk.Box):
]
else:
new_songs_store = [
- [status_icon] + song_model[1:]
+ [
+ (
+ not self.offline_mode
+ or status_icon
+ in ("folder-download-symbolic", "view-pin-symbolic")
+ ),
+ status_icon,
+ *song_model[2:],
+ ]
for status_icon, song_model in zip(
util.get_cached_status_icons(song_ids), self.directory_song_store
)
]
util.diff_song_store(self.directory_song_store, new_songs_store)
+ self.directory_song_list.show()
if len(self.drilldown_directories_store) == 0:
self.list.hide()
@@ -378,6 +451,8 @@ class MusicDirectoryList(Gtk.Box):
# Event Handlers
# ==================================================================================
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
+ if not self.directory_song_store[idx[0]][0]:
+ return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
@@ -412,6 +487,7 @@ class MusicDirectoryList(Gtk.Box):
event.x,
event.y + abs(bin_coords.by - widget_coords.wy),
tree,
+ self.offline_mode,
on_download_state_change=self.on_download_state_change,
)
diff --git a/sublime/ui/common/__init__.py b/sublime/ui/common/__init__.py
index dc2f0d8..fdb4847 100644
--- a/sublime/ui/common/__init__.py
+++ b/sublime/ui/common/__init__.py
@@ -1,6 +1,7 @@
from .album_with_songs import AlbumWithSongs
from .edit_form_dialog import EditFormDialog
-from .icon_button import IconButton, IconToggleButton
+from .icon_button import IconButton, IconMenuButton, IconToggleButton
+from .load_error import LoadError
from .song_list_column import SongListColumn
from .spinner_image import SpinnerImage
@@ -8,7 +9,9 @@ __all__ = (
"AlbumWithSongs",
"EditFormDialog",
"IconButton",
+ "IconMenuButton",
"IconToggleButton",
+ "LoadError",
"SongListColumn",
"SpinnerImage",
)
diff --git a/sublime/ui/common/album_with_songs.py b/sublime/ui/common/album_with_songs.py
index dafe463..727b37b 100644
--- a/sublime/ui/common/album_with_songs.py
+++ b/sublime/ui/common/album_with_songs.py
@@ -1,14 +1,16 @@
from random import randint
-from typing import Any, List
+from typing import Any, cast, List
from gi.repository import Gdk, GLib, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, api_objects as API, Result
from sublime.config import AppConfiguration
from sublime.ui import util
-from sublime.ui.common.icon_button import IconButton
-from sublime.ui.common.song_list_column import SongListColumn
-from sublime.ui.common.spinner_image import SpinnerImage
+
+from .icon_button import IconButton
+from .load_error import LoadError
+from .song_list_column import SongListColumn
+from .spinner_image import SpinnerImage
class AlbumWithSongs(Gtk.Box):
@@ -21,6 +23,8 @@ class AlbumWithSongs(Gtk.Box):
),
}
+ offline_mode = True
+
def __init__(
self,
album: API.Album,
@@ -84,14 +88,14 @@ class AlbumWithSongs(Gtk.Box):
album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
self.play_next_btn = IconButton(
- "go-top-symbolic",
+ "queue-front-symbolic",
"Play all of the songs in this album next",
sensitive=False,
)
album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
self.add_to_queue_btn = IconButton(
- "go-jump-symbolic",
+ "queue-back-symbolic",
"Add all the songs in this album to the end of the play queue",
sensitive=False,
)
@@ -123,8 +127,11 @@ class AlbumWithSongs(Gtk.Box):
self.loading_indicator_container = Gtk.Box()
album_details.add(self.loading_indicator_container)
- # cache status, title, duration, song ID
- self.album_song_store = Gtk.ListStore(str, str, str, str)
+ self.error_container = Gtk.Box()
+ album_details.add(self.error_container)
+
+ # clickable, cache status, title, duration, song ID
+ self.album_song_store = Gtk.ListStore(bool, str, str, str, str)
self.album_songs = Gtk.TreeView(
model=self.album_song_store,
@@ -135,17 +142,19 @@ class AlbumWithSongs(Gtk.Box):
margin_right=10,
margin_bottom=10,
)
- self.album_songs.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
+ selection = self.album_songs.get_selection()
+ selection.set_mode(Gtk.SelectionMode.MULTIPLE)
+ selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
# Song status column.
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35)
- column = Gtk.TreeViewColumn("", renderer, icon_name=0)
+ column = Gtk.TreeViewColumn("", renderer, icon_name=1)
column.set_resizable(True)
self.album_songs.append_column(column)
- self.album_songs.append_column(SongListColumn("TITLE", 1, bold=True))
- self.album_songs.append_column(SongListColumn("DURATION", 2, align=1, width=40))
+ self.album_songs.append_column(SongListColumn("TITLE", 2, bold=True))
+ self.album_songs.append_column(SongListColumn("DURATION", 3, align=1, width=40))
self.album_songs.connect("row-activated", self.on_song_activated)
self.album_songs.connect("button-press-event", self.on_song_button_press)
@@ -165,6 +174,8 @@ class AlbumWithSongs(Gtk.Box):
self.emit("song-selected")
def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
+ if not self.album_song_store[idx[0]][0]:
+ return
# The song ID is in the last column of the model.
self.emit(
"song-clicked",
@@ -202,6 +213,7 @@ class AlbumWithSongs(Gtk.Box):
event.x,
event.y + abs(bin_coords.by - widget_coords.wy),
tree,
+ self.offline_mode,
on_download_state_change=on_download_state_change,
)
@@ -238,8 +250,18 @@ class AlbumWithSongs(Gtk.Box):
def deselect_all(self):
self.album_songs.get_selection().unselect_all()
- def update(self, force: bool = False):
- self.update_album_songs(self.album.id, force=force)
+ def update(self, app_config: AppConfiguration = None, force: bool = False):
+ if app_config:
+ # Deselect everything and reset the error container if switching between
+ # online and offline.
+ if self.offline_mode != app_config.offline_mode:
+ self.album_songs.get_selection().unselect_all()
+ for c in self.error_container.get_children():
+ self.error_container.remove(c)
+
+ self.offline_mode = app_config.offline_mode
+
+ self.update_album_songs(self.album.id, app_config=app_config, force=force)
def set_loading(self, loading: bool):
if loading:
@@ -265,32 +287,63 @@ class AlbumWithSongs(Gtk.Box):
app_config: AppConfiguration,
force: bool = False,
order_token: int = None,
+ is_partial: bool = False,
):
- song_ids = [s.id for s in album.songs or []]
- new_store = [
- [
- cached_status,
- util.esc(song.title),
- util.format_song_duration(song.duration),
- song.id,
- ]
- for cached_status, song in zip(
- util.get_cached_status_icons(song_ids), album.songs or []
- )
- ]
+ songs = album.songs or []
+ if is_partial:
+ if len(self.error_container.get_children()) == 0:
+ load_error = LoadError(
+ "Song list",
+ "retrieve songs",
+ has_data=len(songs) > 0,
+ offline_mode=self.offline_mode,
+ )
+ self.error_container.pack_start(load_error, True, True, 0)
+ self.error_container.show_all()
+ else:
+ self.error_container.hide()
- song_ids = [song[-1] for song in new_store]
+ song_ids = [s.id for s in songs]
+ new_store = []
+ any_song_playable = False
- self.play_btn.set_sensitive(True)
- self.shuffle_btn.set_sensitive(True)
- self.download_all_btn.set_sensitive(AdapterManager.can_batch_download_songs())
+ if len(songs) == 0:
+ self.album_songs.hide()
+ else:
+ self.album_songs.show()
+ for status, song in zip(util.get_cached_status_icons(song_ids), songs):
+ playable = not self.offline_mode or status in (
+ "folder-download-symbolic",
+ "view-pin-symbolic",
+ )
+ new_store.append(
+ [
+ playable,
+ status,
+ util.esc(song.title),
+ util.format_song_duration(song.duration),
+ song.id,
+ ]
+ )
+ any_song_playable |= playable
- self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
- self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
- self.play_next_btn.set_action_name("app.add-to-queue")
- self.add_to_queue_btn.set_action_name("app.play-next")
+ song_ids = [cast(str, song[-1]) for song in new_store]
+ util.diff_song_store(self.album_song_store, new_store)
- util.diff_song_store(self.album_song_store, new_store)
+ self.play_btn.set_sensitive(any_song_playable)
+ self.shuffle_btn.set_sensitive(any_song_playable)
+ self.download_all_btn.set_sensitive(
+ not self.offline_mode and AdapterManager.can_batch_download_songs()
+ )
+
+ if any_song_playable:
+ self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
+ self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
+ self.play_next_btn.set_action_name("app.play-next")
+ self.add_to_queue_btn.set_action_name("app.add-to-queue")
+ else:
+ self.play_next_btn.set_action_name("")
+ self.add_to_queue_btn.set_action_name("")
# Have to idle_add here so that his happens after the component is rendered.
self.set_loading(False)
diff --git a/sublime/ui/common/icon_button.py b/sublime/ui/common/icon_button.py
index f8ad0b1..7233a70 100644
--- a/sublime/ui/common/icon_button.py
+++ b/sublime/ui/common/icon_button.py
@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import Any, Optional
from gi.repository import Gtk
@@ -18,8 +18,7 @@ class IconButton(Gtk.Button):
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
- self.image = Gtk.Image()
- self.image.set_from_icon_name(icon_name, self.icon_size)
+ self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image)
if label is not None:
@@ -49,8 +48,7 @@ class IconToggleButton(Gtk.ToggleButton):
self.icon_size = icon_size
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
- self.image = Gtk.Image()
- self.image.set_from_icon_name(icon_name, self.icon_size)
+ self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
box.add(self.image)
if label is not None:
@@ -70,3 +68,41 @@ class IconToggleButton(Gtk.ToggleButton):
def set_active(self, active: bool):
super().set_active(active)
+
+
+class IconMenuButton(Gtk.MenuButton):
+ def __init__(
+ self,
+ icon_name: Optional[str] = None,
+ tooltip_text: str = "",
+ relief: bool = True,
+ icon_size: Gtk.IconSize = Gtk.IconSize.BUTTON,
+ label: str = None,
+ popover: Any = None,
+ **kwargs,
+ ):
+ Gtk.MenuButton.__init__(self, **kwargs)
+
+ if popover:
+ self.set_use_popover(True)
+ self.set_popover(popover)
+
+ self.icon_size = icon_size
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, name="icon-button-box")
+
+ self.image = Gtk.Image.new_from_icon_name(icon_name, self.icon_size)
+ box.add(self.image)
+
+ if label is not None:
+ box.add(Gtk.Label(label=label))
+
+ self.props.relief = Gtk.ReliefStyle.NORMAL
+
+ self.add(box)
+ self.set_tooltip_text(tooltip_text)
+
+ def set_icon(self, icon_name: Optional[str]):
+ self.image.set_from_icon_name(icon_name, self.icon_size)
+
+ def set_from_file(self, icon_file: Optional[str]):
+ self.image.set_from_file(icon_file)
diff --git a/sublime/ui/common/load_error.py b/sublime/ui/common/load_error.py
new file mode 100644
index 0000000..f59c299
--- /dev/null
+++ b/sublime/ui/common/load_error.py
@@ -0,0 +1,60 @@
+from gi.repository import Gtk
+
+
+class LoadError(Gtk.Box):
+ def __init__(
+ self, entity_name: str, action: str, has_data: bool, offline_mode: bool
+ ):
+ Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
+
+ self.pack_start(Gtk.Box(), True, True, 0)
+
+ inner_box = Gtk.Box(
+ orientation=Gtk.Orientation.HORIZONTAL, name="load-error-box"
+ )
+
+ inner_box.pack_start(Gtk.Box(), True, True, 0)
+
+ error_and_button_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+
+ icon_and_button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+
+ if offline_mode:
+ icon_name = "cloud-offline-symbolic"
+ label = f"{entity_name} may be incomplete.\n" if has_data else ""
+ label += f"Go online to {action}."
+ else:
+ icon_name = "network-error-symbolic"
+ label = f"Error attempting to {action}."
+
+ self.image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
+ self.image.set_name("load-error-image")
+ icon_and_button_box.add(self.image)
+
+ self.label = Gtk.Label(label=label, name="load-error-label")
+ icon_and_button_box.add(self.label)
+
+ error_and_button_box.add(icon_and_button_box)
+
+ button_centerer_box = Gtk.Box()
+ button_centerer_box.pack_start(Gtk.Box(), True, True, 0)
+
+ if offline_mode:
+ go_online_button = Gtk.Button(label="Go Online")
+ go_online_button.set_action_name("app.go-online")
+ button_centerer_box.add(go_online_button)
+ else:
+ retry_button = Gtk.Button(label="Retry")
+ retry_button.set_action_name("app.refresh-window")
+ button_centerer_box.add(retry_button)
+
+ button_centerer_box.pack_start(Gtk.Box(), True, True, 0)
+ error_and_button_box.add(button_centerer_box)
+
+ inner_box.add(error_and_button_box)
+
+ inner_box.pack_start(Gtk.Box(), True, True, 0)
+
+ self.add(inner_box)
+
+ self.pack_start(Gtk.Box(), True, True, 0)
diff --git a/sublime/ui/common/song_list_column.py b/sublime/ui/common/song_list_column.py
index f3a6d0e..e7cbdfe 100644
--- a/sublime/ui/common/song_list_column.py
+++ b/sublime/ui/common/song_list_column.py
@@ -7,9 +7,10 @@ class SongListColumn(Gtk.TreeViewColumn):
header: str,
text_idx: int,
bold: bool = False,
- align: int = 0,
+ align: float = 0,
width: int = None,
):
+ """Represents a column in a song list."""
renderer = Gtk.CellRendererText(
xalign=align,
weight=Pango.Weight.BOLD if bold else Pango.Weight.NORMAL,
@@ -17,6 +18,6 @@ class SongListColumn(Gtk.TreeViewColumn):
)
renderer.set_fixed_size(width or -1, 35)
- super().__init__(header, renderer, text=text_idx)
+ super().__init__(header, renderer, text=text_idx, sensitive=0)
self.set_resizable(True)
self.set_expand(not width)
diff --git a/sublime/ui/common/spinner_image.py b/sublime/ui/common/spinner_image.py
index ca22d85..89dfc16 100644
--- a/sublime/ui/common/spinner_image.py
+++ b/sublime/ui/common/spinner_image.py
@@ -12,6 +12,7 @@ class SpinnerImage(Gtk.Overlay):
image_size: int = None,
**kwargs,
):
+ """An image with a loading overlay."""
Gtk.Overlay.__init__(self)
self.image_size = image_size
self.filename: Optional[str] = None
@@ -28,6 +29,7 @@ class SpinnerImage(Gtk.Overlay):
self.add_overlay(self.spinner)
def set_from_file(self, filename: Optional[str]):
+ """Set the image to the given filename."""
if filename == "":
filename = None
self.filename = filename
diff --git a/sublime/ui/configure_servers.py b/sublime/ui/configure_servers.py
index 55d42af..382dc94 100644
--- a/sublime/ui/configure_servers.py
+++ b/sublime/ui/configure_servers.py
@@ -34,6 +34,7 @@ class EditServerDialog(EditFormDialog):
super().__init__(*args, **kwargs)
+ # TODO (#197) figure out how to do this
# def on_test_server_clicked(self, event: Any):
# # Instantiate the server.
# server_address = self.data["server_address"].get_text()
diff --git a/sublime/ui/icons/chromecast-connected-symbolic.svg b/sublime/ui/icons/chromecast-connected-symbolic.svg
new file mode 100644
index 0000000..da2b1de
--- /dev/null
+++ b/sublime/ui/icons/chromecast-connected-symbolic.svg
@@ -0,0 +1,3 @@
+
diff --git a/sublime/ui/icons/chromecast-connecting-0-symbolic.svg b/sublime/ui/icons/chromecast-connecting-0-symbolic.svg
new file mode 100644
index 0000000..7366a7c
--- /dev/null
+++ b/sublime/ui/icons/chromecast-connecting-0-symbolic.svg
@@ -0,0 +1,8 @@
+
diff --git a/sublime/ui/icons/chromecast-connecting-1-symbolic.svg b/sublime/ui/icons/chromecast-connecting-1-symbolic.svg
new file mode 100644
index 0000000..8870bff
--- /dev/null
+++ b/sublime/ui/icons/chromecast-connecting-1-symbolic.svg
@@ -0,0 +1,8 @@
+
diff --git a/sublime/ui/icons/chromecast-connecting-2-symbolic.svg b/sublime/ui/icons/chromecast-connecting-2-symbolic.svg
new file mode 100644
index 0000000..6e5e810
--- /dev/null
+++ b/sublime/ui/icons/chromecast-connecting-2-symbolic.svg
@@ -0,0 +1,7 @@
+
diff --git a/sublime/ui/icons/chromecast-symbolic.svg b/sublime/ui/icons/chromecast-symbolic.svg
new file mode 100644
index 0000000..9bc8f80
--- /dev/null
+++ b/sublime/ui/icons/chromecast-symbolic.svg
@@ -0,0 +1,3 @@
+
diff --git a/sublime/ui/icons/cloud-offline-symbolic.svg b/sublime/ui/icons/cloud-offline-symbolic.svg
new file mode 100644
index 0000000..7fc2dc7
--- /dev/null
+++ b/sublime/ui/icons/cloud-offline-symbolic.svg
@@ -0,0 +1,3 @@
+
diff --git a/sublime/ui/icons/queue-back-symbolic.svg b/sublime/ui/icons/queue-back-symbolic.svg
new file mode 100644
index 0000000..bbf25bb
--- /dev/null
+++ b/sublime/ui/icons/queue-back-symbolic.svg
@@ -0,0 +1 @@
+
diff --git a/sublime/ui/icons/queue-front-symbolic.svg b/sublime/ui/icons/queue-front-symbolic.svg
new file mode 100644
index 0000000..e8e1662
--- /dev/null
+++ b/sublime/ui/icons/queue-front-symbolic.svg
@@ -0,0 +1 @@
+
diff --git a/sublime/ui/icons/server-connected-symbolic.svg b/sublime/ui/icons/server-connected-symbolic.svg
new file mode 100644
index 0000000..e408399
--- /dev/null
+++ b/sublime/ui/icons/server-connected-symbolic.svg
@@ -0,0 +1,3 @@
+
diff --git a/sublime/ui/icons/server-error-symbolic.svg b/sublime/ui/icons/server-error-symbolic.svg
new file mode 100644
index 0000000..1c0d296
--- /dev/null
+++ b/sublime/ui/icons/server-error-symbolic.svg
@@ -0,0 +1,3 @@
+
diff --git a/sublime/ui/icons/server-offline-symbolic.svg b/sublime/ui/icons/server-offline-symbolic.svg
new file mode 100644
index 0000000..b04bccf
--- /dev/null
+++ b/sublime/ui/icons/server-offline-symbolic.svg
@@ -0,0 +1,3 @@
+
diff --git a/sublime/ui/icons/server-subsonic-connected-symbolic.svg b/sublime/ui/icons/server-subsonic-connected-symbolic.svg
new file mode 100644
index 0000000..016be07
--- /dev/null
+++ b/sublime/ui/icons/server-subsonic-connected-symbolic.svg
@@ -0,0 +1,4 @@
+
diff --git a/sublime/ui/icons/server-subsonic-error-symbolic.svg b/sublime/ui/icons/server-subsonic-error-symbolic.svg
new file mode 100644
index 0000000..624137b
--- /dev/null
+++ b/sublime/ui/icons/server-subsonic-error-symbolic.svg
@@ -0,0 +1,4 @@
+
diff --git a/sublime/ui/icons/server-subsonic-offline-symbolic.svg b/sublime/ui/icons/server-subsonic-offline-symbolic.svg
new file mode 100644
index 0000000..b03df0c
--- /dev/null
+++ b/sublime/ui/icons/server-subsonic-offline-symbolic.svg
@@ -0,0 +1,4 @@
+
diff --git a/sublime/ui/images/play-queue-play.svg b/sublime/ui/images/play-queue-play.svg
index b17b764..2e747cb 100644
--- a/sublime/ui/images/play-queue-play.svg
+++ b/sublime/ui/images/play-queue-play.svg
@@ -1,97 +1,4 @@
-
-
-
-