Merge branch '64-current-downloads-dropdown'
This commit is contained in:
@@ -17,6 +17,7 @@ Features
|
||||
* 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.
|
||||
* "Go to Album" is much more reliable.
|
||||
|
||||
**Player Controls**
|
||||
|
||||
@@ -26,7 +27,8 @@ Features
|
||||
|
||||
**New Icons**
|
||||
|
||||
* The Devices button now uses the Chromecast logo.
|
||||
* The Devices button now uses the Chromecast logo. It uses a different icon
|
||||
depending on whether or not you are playing on a Chromecast.
|
||||
* 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.
|
||||
@@ -36,14 +38,26 @@ Features
|
||||
|
||||
**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.
|
||||
* **Settings**
|
||||
|
||||
.. * The music provider configuration has gotten a major revamp.
|
||||
.. * The Downloads popup shows the songs that are currently being downloaded.
|
||||
.. *
|
||||
* Settings are now in the popup under the gear icon rather than in a separate
|
||||
popup window.
|
||||
|
||||
* **Downloads**
|
||||
|
||||
* A new Downloads popup shows the currently downloading songs and allows you
|
||||
to cancel song downloads and retry failed downloads.
|
||||
* 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.
|
||||
|
||||
* **Server**
|
||||
|
||||
* A new Server popup shows the connection state to the server in both the icon
|
||||
and the popup.
|
||||
* You can enable *Offline Mode* (see below for details).
|
||||
|
||||
.. * The "Configure Servers" dialog has been eliminated in favor of a much more
|
||||
intuitive set of controls right on the Server popup.
|
||||
|
||||
**Offline Mode**
|
||||
|
||||
@@ -53,6 +67,9 @@ Features
|
||||
|
||||
**Other Features**
|
||||
|
||||
* You can now collapse the Artist details and the Playlist details so that you
|
||||
have more room to view the actual content.
|
||||
|
||||
.. * A man page has been added. Contributed by @baldurmen.
|
||||
|
||||
Under The Hood
|
||||
|
@@ -5,7 +5,7 @@
|
||||
Welcome to Sublime Music's documentation!
|
||||
=========================================
|
||||
|
||||
Sublime Music is a GTK3
|
||||
Sublime Music is a native, GTK3
|
||||
`Subsonic`_/`Airsonic`_/`Revel`_/`Gonic`_/`Navidrome`_/\*sonic client for the
|
||||
Linux Desktop.
|
||||
|
||||
@@ -26,16 +26,17 @@ Linux Desktop.
|
||||
Features
|
||||
--------
|
||||
|
||||
- Switch between multiple Subsonic-API-compliant servers.
|
||||
- Play music through Chromecast devices on the same LAN.
|
||||
- DBus MPRIS interface integration for controlling Sublime Music via DBus MPRIS
|
||||
clients such as ``playerctl``, ``i3status-rust``, KDE Connect, and many
|
||||
commonly used desktop environments.
|
||||
- Browse songs by the sever-reported filesystem structure, or view them
|
||||
* Switch between multiple Subsonic-API-compliant servers.
|
||||
* Play music through Chromecast devices on the same LAN.
|
||||
* Offline Mode where Sublime Music will not make any network requests.
|
||||
* DBus MPRIS interface integration for controlling Sublime Music via clients
|
||||
such as ``playerctl``, ``i3status-rust``, KDE Connect, and many commonly used
|
||||
desktop environments.
|
||||
* Browse songs by the sever-reported filesystem structure, or view them
|
||||
organized by ID3 tags in the Albums, Artists, and Playlists views.
|
||||
- Intuitive play queue.
|
||||
- Create/delete/edit playlists.
|
||||
- Cache songs for offline listening.
|
||||
* Intuitive play queue.
|
||||
* Create/delete/edit playlists.
|
||||
* Download songs for offline listening.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
@@ -2,14 +2,32 @@ Settings
|
||||
########
|
||||
|
||||
There are many settings available in Sublime Music. Some are application-wide
|
||||
settings while others are are configurable at a per-server basis.
|
||||
settings while others are are configurable on a per-music-provider basis.
|
||||
|
||||
Application Settings
|
||||
--------------------
|
||||
|
||||
The following settings can be changed for the entire application.
|
||||
The following settings can be changed for the entire application. They can be
|
||||
accessed via the gear icon in the header.
|
||||
|
||||
Port Number : (int)
|
||||
Enable Song Notifications : (``bool``)
|
||||
If toggled on, a notification will be shown whenever a new song starts to
|
||||
play. The notification will contain the title, artist name, album name, and
|
||||
cover art of the song.
|
||||
|
||||
Replay Gain : (Disabled | Track | Album)
|
||||
Configures the replay gain setting for the MPV player. You can disable this
|
||||
setting, or configure it to work on a track or album basis.
|
||||
|
||||
Serve Local Files to Chromecasts on the LAN : (``bool``)
|
||||
If checked, a local server will be started on your computer which will serve
|
||||
your locally cached music files to the Chromecast. If not checked, the
|
||||
Chromecast will always stream from the server.
|
||||
|
||||
If you are having trouble playing on your Chromecast, try disabling this
|
||||
feature.
|
||||
|
||||
LAN Server Port Number : (int)
|
||||
The port number to use for streaming to Chromecast devices on the same
|
||||
LAN.
|
||||
|
||||
@@ -17,39 +35,24 @@ Port Number : (int)
|
||||
already cached locally, the Chromecast will connect to your computer and
|
||||
stream from it instead of from the internet.
|
||||
|
||||
This will not take effect until the application is restarted.
|
||||
Allow Song Downloads : (bool)
|
||||
If toggled on, this will allow songs to be downloaded and cached locally.
|
||||
|
||||
Replay Gain : (Disabled | Track | Album)
|
||||
Configures the replay gain setting for the MPV player. You can disable this
|
||||
setting, or configure it to work on a track or album basis.
|
||||
|
||||
Always stream songs : (bool)
|
||||
If checked, this will disable using the local song cache.
|
||||
|
||||
When streaming, also download song : (bool)
|
||||
If checked, when a song is streamed, it will also be downloaded. Once the
|
||||
When Streaming, Also Download Song : (bool)
|
||||
If toggled on, when a song is streamed, it will also be downloaded. Once the
|
||||
download is complete, Sublime Music will stop streaming and switch to the
|
||||
downloaded version.
|
||||
downloaded version (if playing locally with MPV).
|
||||
|
||||
Show notification when song begins to play : (bool)
|
||||
If checked, a notification containing the new song's title, artist, album,
|
||||
and album art will be shown through your notification daemon.
|
||||
|
||||
Serve locally cached files over the LAN to Chromecast devices : (bool)
|
||||
If checked, a local server will be started on your computer which will serve
|
||||
your locally cached music files to the Chromecast. If not checked, the
|
||||
Chromecast will always stream from the server.
|
||||
|
||||
How many songs in the play queue do you want to prefetch? : (int)
|
||||
Number of Songs to Prefetch : (int)
|
||||
If the next :math:`n` songs in the play queue are not already downloaded,
|
||||
they will be downloaded. (This has no effect if *Always stream songs* is
|
||||
checked.)
|
||||
they will be downloaded.
|
||||
|
||||
How many song downloads do you want to allow concurrently? : (int)
|
||||
Specifies how many songs can be downloaded at the same time.
|
||||
Maximum Concurrent Downloads : (int)
|
||||
Specifies the maximum number of songs that should be downloaded at the same
|
||||
time.
|
||||
|
||||
Server Settings
|
||||
---------------
|
||||
Subsonic Server Settings
|
||||
------------------------
|
||||
|
||||
Each server has the following configuration options:
|
||||
|
||||
|
17
setup.py
17
setup.py
@@ -1,20 +1,26 @@
|
||||
import codecs
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
here = Path(__file__).parent.resolve()
|
||||
|
||||
with codecs.open(os.path.join(here, "README.rst"), encoding="utf-8") as f:
|
||||
with open(here.joinpath("README.rst"), encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
|
||||
# Find the version
|
||||
with codecs.open(os.path.join(here, "sublime/__init__.py"), encoding="utf-8") as f:
|
||||
with open(here.joinpath("sublime", "__init__.py")) as f:
|
||||
for line in f:
|
||||
if line.startswith("__version__"):
|
||||
version = eval(line.split()[-1])
|
||||
break
|
||||
|
||||
icons_dir = here.joinpath("sublime", "ui", "icons")
|
||||
icon_filenames = []
|
||||
for icon in icons_dir.iterdir():
|
||||
if not str(icon).endswith(".svg"):
|
||||
continue
|
||||
icon_filenames.append(str(icon))
|
||||
|
||||
setup(
|
||||
name="sublime-music",
|
||||
version=version,
|
||||
@@ -49,6 +55,7 @@ setup(
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.Player.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml",
|
||||
"dbus/mpris_specs/org.mpris.MediaPlayer2.TrackList.xml",
|
||||
*icon_filenames,
|
||||
]
|
||||
},
|
||||
install_requires=[
|
||||
|
@@ -5,7 +5,7 @@
|
||||
<metadata_license>FSFAP</metadata_license>
|
||||
<project_license>GPL-3.0+</project_license>
|
||||
<name>Sublime Music</name>
|
||||
<summary>A native GTK music player with *sonic support</summary>
|
||||
<summary>Native Subsonic client for Linux</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
|
@@ -16,6 +16,7 @@ from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
cast,
|
||||
Dict,
|
||||
Generic,
|
||||
Iterable,
|
||||
List,
|
||||
@@ -80,6 +81,7 @@ class Result(Generic[T]):
|
||||
_future: Optional[Future] = None
|
||||
_default_value: Optional[T] = None
|
||||
_on_cancel: Optional[Callable[[], None]] = None
|
||||
_cancelled = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -152,8 +154,12 @@ class Result(Generic[T]):
|
||||
self._on_cancel()
|
||||
if self._future is not None:
|
||||
return self._future.cancel()
|
||||
self._cancelled = True
|
||||
return True
|
||||
|
||||
def cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
@property
|
||||
def data_is_available(self) -> bool:
|
||||
"""
|
||||
@@ -180,7 +186,7 @@ class DownloadProgress:
|
||||
|
||||
@property
|
||||
def progress_fraction(self) -> Optional[float]:
|
||||
if not self.current_bytes or not self.total_bytes:
|
||||
if self.current_bytes is None or self.total_bytes is None:
|
||||
return None
|
||||
return self.current_bytes / self.total_bytes
|
||||
|
||||
@@ -194,6 +200,9 @@ class AdapterManager:
|
||||
is_shutting_down: bool = False
|
||||
_offline_mode: bool = False
|
||||
|
||||
_song_download_jobs: Dict[str, Result[str]] = {}
|
||||
_cancelled_song_ids: Set[str] = set()
|
||||
|
||||
@dataclass
|
||||
class _AdapterManagerInternal:
|
||||
ground_truth_adapter: Adapter
|
||||
@@ -253,6 +262,9 @@ class AdapterManager:
|
||||
def shutdown():
|
||||
logging.info("AdapterManager shutdown start")
|
||||
AdapterManager.is_shutting_down = True
|
||||
for _, job in AdapterManager._song_download_jobs.items():
|
||||
job.cancel()
|
||||
|
||||
AdapterManager.executor.shutdown()
|
||||
AdapterManager.download_executor.shutdown()
|
||||
if AdapterManager._instance:
|
||||
@@ -377,17 +389,19 @@ class AdapterManager:
|
||||
return Result(future_fn)
|
||||
|
||||
@staticmethod
|
||||
def _create_download_fn(
|
||||
def _create_download_result(
|
||||
uri: str,
|
||||
id: str,
|
||||
before_download: Callable[[], None] = None,
|
||||
expected_size: int = None,
|
||||
) -> Callable[[], str]:
|
||||
**result_args,
|
||||
) -> Result[str]:
|
||||
"""
|
||||
Create a function to download the given URI to a temporary file, and return the
|
||||
filename. The returned function will spin-loop if the resource is already being
|
||||
downloaded to prevent multiple requests for the same download.
|
||||
"""
|
||||
download_cancelled = False
|
||||
|
||||
def download_fn() -> str:
|
||||
assert AdapterManager._instance
|
||||
@@ -405,6 +419,15 @@ class AdapterManager:
|
||||
before_download()
|
||||
|
||||
expected_size_exists = expected_size is not None
|
||||
if expected_size_exists:
|
||||
AdapterManager._instance.song_download_progress(
|
||||
id,
|
||||
DownloadProgress(
|
||||
DownloadProgress.Type.PROGRESS,
|
||||
total_bytes=expected_size,
|
||||
current_bytes=0,
|
||||
),
|
||||
)
|
||||
|
||||
# TODO (#122): figure out how to retry if the other request failed.
|
||||
if resource_downloading:
|
||||
@@ -454,9 +477,16 @@ class AdapterManager:
|
||||
total_consumed += len(data)
|
||||
f.write(data)
|
||||
|
||||
if i % 5 == 0:
|
||||
if download_cancelled:
|
||||
AdapterManager._instance.song_download_progress(
|
||||
id,
|
||||
DownloadProgress(DownloadProgress.Type.CANCELLED),
|
||||
)
|
||||
raise Exception("Download Cancelled")
|
||||
|
||||
if i % 100 == 0:
|
||||
# Only delay (if configured) and update the progress UI
|
||||
# every 5 KiB.
|
||||
# every 100 KiB.
|
||||
if DOWNLOAD_BLOCK_DELAY is not None:
|
||||
sleep(DOWNLOAD_BLOCK_DELAY)
|
||||
|
||||
@@ -476,12 +506,14 @@ class AdapterManager:
|
||||
id, DownloadProgress(DownloadProgress.Type.DONE),
|
||||
)
|
||||
except Exception as e:
|
||||
if expected_size_exists:
|
||||
if expected_size_exists and not download_cancelled:
|
||||
# Something failed. Post an error.
|
||||
AdapterManager._instance.song_download_progress(
|
||||
id,
|
||||
DownloadProgress(DownloadProgress.Type.ERROR, exception=e),
|
||||
)
|
||||
# Re-raise the exception so that we can actually handle it.
|
||||
raise
|
||||
finally:
|
||||
# Always release the download set lock, even if there's an error.
|
||||
with AdapterManager.download_set_lock:
|
||||
@@ -490,7 +522,13 @@ class AdapterManager:
|
||||
logging.info(f"{uri} downloaded. Returning.")
|
||||
return str(download_tmp_filename)
|
||||
|
||||
return download_fn
|
||||
def on_download_cancel():
|
||||
nonlocal download_cancelled
|
||||
download_cancelled = True
|
||||
|
||||
return Result(
|
||||
download_fn, is_download=True, on_cancel=on_download_cancel, **result_args
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_caching_done_callback(
|
||||
@@ -851,15 +889,12 @@ class AdapterManager:
|
||||
):
|
||||
return Result(existing_cover_art_filename)
|
||||
|
||||
future: Result[str] = Result(
|
||||
AdapterManager._create_download_fn(
|
||||
AdapterManager._instance.ground_truth_adapter.get_cover_art_uri(
|
||||
cover_art_id, AdapterManager._get_scheme(), size=300
|
||||
),
|
||||
cover_art_id,
|
||||
before_download,
|
||||
future: Result[str] = AdapterManager._create_download_result(
|
||||
AdapterManager._instance.ground_truth_adapter.get_cover_art_uri(
|
||||
cover_art_id, AdapterManager._get_scheme(), size=300
|
||||
),
|
||||
is_download=True,
|
||||
cover_art_id,
|
||||
before_download,
|
||||
default_value=existing_cover_art_filename,
|
||||
)
|
||||
|
||||
@@ -930,63 +965,72 @@ class AdapterManager:
|
||||
return Result(None)
|
||||
|
||||
cancelled = False
|
||||
AdapterManager._cancelled_song_ids -= set(song_ids)
|
||||
|
||||
def do_download_song(song_id: str):
|
||||
assert AdapterManager._instance
|
||||
assert AdapterManager._instance.caching_adapter
|
||||
|
||||
if (
|
||||
AdapterManager.is_shutting_down
|
||||
or AdapterManager._offline_mode
|
||||
or cancelled
|
||||
or song_id in AdapterManager._cancelled_song_ids
|
||||
):
|
||||
AdapterManager._instance.download_limiter_semaphore.release()
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.CANCELLED),
|
||||
)
|
||||
return
|
||||
|
||||
assert AdapterManager._instance
|
||||
assert AdapterManager._instance.caching_adapter
|
||||
|
||||
logging.info(f"Downloading {song_id}")
|
||||
|
||||
# Download the actual song file.
|
||||
try:
|
||||
# Download the actual song file.
|
||||
try:
|
||||
# If the song file is already cached, return immediately.
|
||||
AdapterManager._instance.caching_adapter.get_song_uri(
|
||||
song_id, "file"
|
||||
)
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.DONE),
|
||||
)
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
except CacheMissError:
|
||||
# The song is not already cached.
|
||||
if before_download:
|
||||
before_download(song_id)
|
||||
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
|
||||
# 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()
|
||||
),
|
||||
song_id,
|
||||
lambda: before_download(song_id),
|
||||
expected_size=song.size,
|
||||
)()
|
||||
AdapterManager._instance.caching_adapter.ingest_new_data(
|
||||
CachingAdapter.CachedDataKey.SONG_FILE,
|
||||
song_id,
|
||||
(None, song_tmp_filename, None),
|
||||
)
|
||||
on_song_download_complete(song_id)
|
||||
|
||||
# Download the corresponding cover art.
|
||||
AdapterManager.get_cover_art_filename(song.cover_art).result()
|
||||
finally:
|
||||
# Release the semaphore lock. This will allow the next song in the queue
|
||||
# to be downloaded. I'm doing this in the finally block so that it
|
||||
# always runs, regardless of whether an exception is thrown or the
|
||||
# function returns.
|
||||
# If the song file is already cached, just indicate done immediately.
|
||||
AdapterManager._instance.caching_adapter.get_song_uri(song_id, "file")
|
||||
AdapterManager._instance.download_limiter_semaphore.release()
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.DONE),
|
||||
)
|
||||
except CacheMissError:
|
||||
# The song is not already cached.
|
||||
if before_download:
|
||||
before_download(song_id)
|
||||
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
|
||||
# Download the song.
|
||||
song_tmp_filename_result: Result[
|
||||
str
|
||||
] = AdapterManager._create_download_result(
|
||||
AdapterManager._instance.ground_truth_adapter.get_song_uri(
|
||||
song_id, AdapterManager._get_scheme()
|
||||
),
|
||||
song_id,
|
||||
lambda: before_download(song_id),
|
||||
expected_size=song.size,
|
||||
)
|
||||
|
||||
def on_download_done(f: Result):
|
||||
assert AdapterManager._instance
|
||||
assert AdapterManager._instance.caching_adapter
|
||||
AdapterManager._instance.download_limiter_semaphore.release()
|
||||
|
||||
if AdapterManager._song_download_jobs.get(song_id):
|
||||
del AdapterManager._song_download_jobs[song_id]
|
||||
|
||||
try:
|
||||
AdapterManager._instance.caching_adapter.ingest_new_data(
|
||||
CachingAdapter.CachedDataKey.SONG_FILE,
|
||||
song_id,
|
||||
(None, f.result(), None),
|
||||
)
|
||||
finally:
|
||||
on_song_download_complete(song_id)
|
||||
|
||||
song_tmp_filename_result.add_done_callback(on_download_done)
|
||||
AdapterManager._song_download_jobs[song_id] = song_tmp_filename_result
|
||||
|
||||
def do_batch_download_songs():
|
||||
sleep(delay)
|
||||
@@ -997,7 +1041,7 @@ class AdapterManager:
|
||||
):
|
||||
return
|
||||
|
||||
# Alert the UI that the downloads are queue.
|
||||
# Alert the UI that the downloads are queued.
|
||||
for song_id in song_ids:
|
||||
# Everything succeeded.
|
||||
AdapterManager._instance.song_download_progress(
|
||||
@@ -1005,12 +1049,6 @@ class AdapterManager:
|
||||
)
|
||||
|
||||
for song_id in song_ids:
|
||||
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()
|
||||
@@ -1025,15 +1063,31 @@ class AdapterManager:
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
# Cancel the individual song downloads
|
||||
AdapterManager.cancel_download_songs(song_ids)
|
||||
|
||||
# Alert the UI that the downloads are cancelled.
|
||||
for song_id in song_ids:
|
||||
# Everything succeeded.
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.CANCELLED),
|
||||
)
|
||||
|
||||
return Result(do_batch_download_songs, is_download=True, on_cancel=on_cancel)
|
||||
|
||||
@staticmethod
|
||||
def cancel_download_songs(song_ids: Sequence[str]):
|
||||
assert AdapterManager._instance
|
||||
AdapterManager._cancelled_song_ids = AdapterManager._cancelled_song_ids.union(
|
||||
set(song_ids)
|
||||
)
|
||||
for song_id in song_ids:
|
||||
AdapterManager._instance.song_download_progress(
|
||||
song_id, DownloadProgress(DownloadProgress.Type.CANCELLED),
|
||||
)
|
||||
if AdapterManager._song_download_jobs.get(song_id):
|
||||
AdapterManager._song_download_jobs[song_id].cancel()
|
||||
del AdapterManager._song_download_jobs[song_id]
|
||||
|
||||
@staticmethod
|
||||
def batch_permanently_cache_songs(
|
||||
song_ids: Sequence[str],
|
||||
@@ -1358,7 +1412,10 @@ class AdapterManager:
|
||||
)
|
||||
return [
|
||||
SongCacheStatus.DOWNLOADING
|
||||
if song_id in AdapterManager.current_download_ids
|
||||
if (
|
||||
song_id in AdapterManager.current_download_ids
|
||||
and song_id not in AdapterManager._cancelled_song_ids
|
||||
)
|
||||
else cached_statuses[song_id]
|
||||
for song_id in song_ids
|
||||
]
|
||||
|
@@ -791,12 +791,13 @@ class SublimeMusicApp(Gtk.Application):
|
||||
)
|
||||
|
||||
# If already playing, then make the player itself seek.
|
||||
if self.player.song_loaded:
|
||||
if self.player and self.player.song_loaded:
|
||||
self.player.seek(new_time)
|
||||
|
||||
self.save_play_queue()
|
||||
|
||||
def on_device_update(self, win: Any, device_uuid: str):
|
||||
assert self.player
|
||||
if device_uuid == self.app_config.state.current_device:
|
||||
return
|
||||
self.app_config.state.current_device = device_uuid
|
||||
@@ -865,7 +866,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
|
||||
def on_song_download_progress(self, song_id: str, progress: DownloadProgress):
|
||||
assert self.window
|
||||
self.window.update_song_download_progress(song_id, progress)
|
||||
GLib.idle_add(self.window.update_song_download_progress, song_id, progress)
|
||||
|
||||
def on_app_shutdown(self, app: "SublimeMusicApp"):
|
||||
self.exiting = True
|
||||
@@ -997,6 +998,7 @@ class SublimeMusicApp(Gtk.Application):
|
||||
# in the callback.
|
||||
@dbus_propagate(self)
|
||||
def do_play_song(order_token: int, song: Song):
|
||||
assert self.player
|
||||
if order_token != self.song_playing_order_token:
|
||||
return
|
||||
|
||||
|
@@ -4,7 +4,7 @@ import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
from typing import Any, Callable, DefaultDict, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, DefaultDict, Dict, List, Match, Optional, Tuple
|
||||
|
||||
from deepdiff import DeepDiff
|
||||
from gi.repository import Gio, GLib
|
||||
@@ -163,6 +163,29 @@ class DBusManager:
|
||||
return value
|
||||
return GLib.Variant(variant_type, value)
|
||||
|
||||
_escape_re = re.compile(r"[^\w]+")
|
||||
|
||||
@staticmethod
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _escape_id(id: str) -> str:
|
||||
"""
|
||||
Escapes an ID for use in a DBus object identifier.
|
||||
|
||||
>>> DBusManager._escape_id("tr-1843")
|
||||
'tr_0x45_1843'
|
||||
>>> DBusManager._escape_id("bc9c7726-8739-4add-8df0-88c6233f37df")
|
||||
'bc9c7726_0x45_8739_0x45_4add_0x45_8df0_0x45_88c6233f37df'
|
||||
>>> DBusManager._escape_id("spaces spaces everywhere")
|
||||
'spaces_0x32_spaces_0x32_everywhere'
|
||||
>>> DBusManager._escape_id("already/has/slashes")
|
||||
'already_0x47_has_0x47_slashes'
|
||||
"""
|
||||
|
||||
def replace(m: Match[str]) -> str:
|
||||
return f"_0x{ord(m[0])}_"
|
||||
|
||||
return DBusManager._escape_re.sub(replace, id)
|
||||
|
||||
def property_dict(self) -> Dict[str, Any]:
|
||||
config, player = self.get_config_and_player()
|
||||
if config is None or player is None:
|
||||
@@ -229,7 +252,7 @@ class DBusManager:
|
||||
"CanControl": True,
|
||||
},
|
||||
"org.mpris.MediaPlayer2.TrackList": {
|
||||
"Tracks": self.get_dbus_playlist(state.play_queue),
|
||||
"Tracks": DBusManager.get_dbus_playlist(state.play_queue),
|
||||
"CanEditTracks": False,
|
||||
},
|
||||
"org.mpris.MediaPlayer2.Playlists": {
|
||||
@@ -261,7 +284,12 @@ class DBusManager:
|
||||
return (
|
||||
True,
|
||||
GLib.Variant(
|
||||
"(oss)", ("/playlist/" + playlist.id, playlist.name, cover_art)
|
||||
"(oss)",
|
||||
(
|
||||
"/playlist/" + DBusManager._escape_id(playlist.id),
|
||||
playlist.name,
|
||||
cover_art,
|
||||
),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
@@ -279,7 +307,7 @@ class DBusManager:
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
trackid = self.get_dbus_playlist(play_queue)[idx]
|
||||
trackid = DBusManager.get_dbus_playlist(play_queue)[idx]
|
||||
duration = (
|
||||
"x",
|
||||
int(
|
||||
@@ -307,13 +335,22 @@ class DBusManager:
|
||||
"xesam:title": song.title,
|
||||
}
|
||||
|
||||
@functools.lru_cache(maxsize=10)
|
||||
def get_dbus_playlist(self, play_queue: Tuple[str, ...]) -> List[str]:
|
||||
@staticmethod
|
||||
@functools.lru_cache(maxsize=20)
|
||||
def get_dbus_playlist(play_queue: Tuple[str, ...]) -> List[str]:
|
||||
"""
|
||||
Gets a playlist formatted for DBus. If multiples of the same element exist in
|
||||
the queue, it will use ``/0`` after the song ID to differentiate between the
|
||||
instances.
|
||||
|
||||
>>> DBusManager.get_dbus_playlist(("2", "1", "3", "1"))
|
||||
['/song/2/0', '/song/1/0', '/song/3/0', '/song/1/1']
|
||||
"""
|
||||
seen_counts: DefaultDict[str, int] = defaultdict(int)
|
||||
tracks = []
|
||||
for song_id in play_queue:
|
||||
id_ = seen_counts[song_id]
|
||||
tracks.append(f"/song/{song_id}/{id_}")
|
||||
num_seen = seen_counts[song_id]
|
||||
tracks.append(f"/song/{DBusManager._escape_id(song_id)}/{num_seen}")
|
||||
seen_counts[song_id] += 1
|
||||
|
||||
return tracks
|
||||
|
@@ -32,6 +32,15 @@
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#current-downloads-list-pending-count,
|
||||
#current-downloads-list-failed-count {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
#current-downloads-cover-art-image {
|
||||
margin: 3px 5px;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
@@ -34,7 +34,12 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
}
|
||||
|
||||
_updating_settings: bool = False
|
||||
_pending_downloads: Set[str] = set()
|
||||
_failed_downloads: Set[str] = set()
|
||||
_current_download_boxes: Dict[str, Gtk.Box] = {}
|
||||
_failed_downloads_box: Optional[Gtk.Label] = None
|
||||
_pending_downloads_label: Optional[Gtk.Label] = None
|
||||
_current_downloads_placeholder: Optional[Gtk.Label] = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -160,13 +165,20 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
|
||||
self._updating_settings = True
|
||||
|
||||
# Main Settings
|
||||
# Offline Mode Settings
|
||||
offline_mode = app_config.offline_mode
|
||||
self.offline_mode_switch.set_active(offline_mode)
|
||||
|
||||
# Main Settings
|
||||
self.notification_switch.set_active(app_config.song_play_notification)
|
||||
|
||||
# MPV Settings
|
||||
self.replay_gain_options.set_active_id(app_config.replay_gain.as_string())
|
||||
|
||||
# Chromecast Settings
|
||||
self.serve_over_lan_switch.set_active(app_config.serve_over_lan)
|
||||
self.port_number_entry.set_value(app_config.port_number)
|
||||
self.port_number_entry.set_sensitive(app_config.serve_over_lan)
|
||||
|
||||
# Download Settings
|
||||
allow_song_downloads = app_config.allow_song_downloads
|
||||
@@ -192,36 +204,136 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
|
||||
def update_song_download_progress(self, song_id: str, progress: DownloadProgress):
|
||||
if progress.type == DownloadProgress.Type.QUEUED:
|
||||
# Create and add the box to show the progress.
|
||||
self._current_download_boxes[song_id] = DownloadStatusBox(song_id)
|
||||
self.current_downloads_box.add(self._current_download_boxes[song_id])
|
||||
if (
|
||||
song_id not in self._failed_downloads
|
||||
and song_id not in self._current_download_boxes.keys()
|
||||
):
|
||||
self._pending_downloads.add(song_id)
|
||||
elif progress.type in (
|
||||
DownloadProgress.Type.DONE,
|
||||
DownloadProgress.Type.CANCELLED,
|
||||
):
|
||||
# Remove and delet the box for the download.
|
||||
# Remove and delete the box for the download if it exists.
|
||||
if song_id in self._current_download_boxes:
|
||||
self.current_downloads_box.remove(self._current_download_boxes[song_id])
|
||||
del self._current_download_boxes[song_id]
|
||||
|
||||
# The download is no longer pending.
|
||||
if song_id in self._pending_downloads:
|
||||
self._pending_downloads.remove(song_id)
|
||||
elif progress.type == DownloadProgress.Type.ERROR:
|
||||
GLib.idle_add(
|
||||
self._current_download_boxes[song_id].set_error, progress.exception
|
||||
)
|
||||
self._failed_downloads.add(song_id)
|
||||
self.current_downloads_box.remove(self._current_download_boxes[song_id])
|
||||
del self._current_download_boxes[song_id]
|
||||
elif progress.type == DownloadProgress.Type.PROGRESS:
|
||||
GLib.idle_add(
|
||||
self._current_download_boxes[song_id].update_progress,
|
||||
progress.progress_fraction,
|
||||
if song_id not in self._current_download_boxes:
|
||||
# Create and add the box to show the progress.
|
||||
self._current_download_boxes[song_id] = DownloadStatusBox(song_id)
|
||||
self._current_download_boxes[song_id].connect(
|
||||
"cancel-clicked", self._on_download_box_cancel_click
|
||||
)
|
||||
self.current_downloads_box.add(self._current_download_boxes[song_id])
|
||||
|
||||
if song_id in self._pending_downloads:
|
||||
self._pending_downloads.remove(song_id)
|
||||
if song_id in self._failed_downloads:
|
||||
self._failed_downloads.remove(song_id)
|
||||
|
||||
self._current_download_boxes[song_id].update_progress(
|
||||
progress.progress_fraction
|
||||
)
|
||||
|
||||
if len(self._current_download_boxes) == 0:
|
||||
self.current_downloads_placeholder.show()
|
||||
# Show or hide the "failed count" indicator.
|
||||
failed_download_count = len(self._failed_downloads)
|
||||
if failed_download_count > 0:
|
||||
if not self._failed_downloads_box:
|
||||
self._failed_downloads_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL
|
||||
)
|
||||
|
||||
self._failed_downloads_label = Gtk.Label(
|
||||
label="",
|
||||
halign=Gtk.Align.START,
|
||||
name="current-downloads-list-failed-count",
|
||||
)
|
||||
self._failed_downloads_box.add(self._failed_downloads_label)
|
||||
|
||||
retry_all_button = IconButton(
|
||||
"view-refresh-symbolic", tooltip_text="Retry all failed downloads."
|
||||
)
|
||||
retry_all_button.connect("clicked", self._on_retry_all_clicked)
|
||||
self._failed_downloads_box.pack_end(retry_all_button, False, False, 0)
|
||||
|
||||
self.current_downloads_box.pack_start(
|
||||
self._failed_downloads_box, False, False, 5
|
||||
)
|
||||
songs = util.pluralize("song", failed_download_count)
|
||||
self._failed_downloads_label.set_text(
|
||||
f"{failed_download_count} {songs} failed to download"
|
||||
)
|
||||
else:
|
||||
self.current_downloads_placeholder.hide()
|
||||
if self._failed_downloads_box:
|
||||
self.current_downloads_box.remove(self._failed_downloads_box)
|
||||
self._failed_downloads_box = None
|
||||
|
||||
def _create_download_status_box(self, song_id: str) -> Gtk.Box:
|
||||
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
# Show or hide the "pending count" indicator.
|
||||
pending_download_count = len(self._pending_downloads)
|
||||
if pending_download_count > 0:
|
||||
if not self._pending_downloads_label:
|
||||
self._pending_downloads_label = Gtk.Label(
|
||||
label="",
|
||||
halign=Gtk.Align.START,
|
||||
name="current-downloads-list-pending-count",
|
||||
)
|
||||
self.current_downloads_box.pack_end(
|
||||
self._pending_downloads_label, False, False, 5
|
||||
)
|
||||
songs = util.pluralize("song", pending_download_count)
|
||||
self._pending_downloads_label.set_text(
|
||||
f"+{pending_download_count} pending {songs}"
|
||||
)
|
||||
else:
|
||||
if self._pending_downloads_label:
|
||||
self.current_downloads_box.remove(self._pending_downloads_label)
|
||||
self._pending_downloads_label = None
|
||||
|
||||
return status_box
|
||||
# Show or hide the placeholder depending on whether or not there's anything to
|
||||
# show.
|
||||
current_downloads = (
|
||||
len(self._current_download_boxes)
|
||||
+ pending_download_count
|
||||
+ failed_download_count
|
||||
)
|
||||
if current_downloads == 0:
|
||||
if not self._current_downloads_placeholder:
|
||||
self._current_downloads_placeholder = Gtk.Label(
|
||||
label="<i>No current downloads</i>",
|
||||
use_markup=True,
|
||||
name="current-downloads-list-placeholder",
|
||||
)
|
||||
self.current_downloads_box.add(self._current_downloads_placeholder)
|
||||
else:
|
||||
if self._current_downloads_placeholder:
|
||||
self.current_downloads_box.remove(self._current_downloads_placeholder)
|
||||
self._current_downloads_placeholder = None
|
||||
|
||||
self.current_downloads_box.show_all()
|
||||
|
||||
self.cancel_all_button.set_sensitive(current_downloads > 0)
|
||||
|
||||
def _on_cancel_all_clicked(self, _):
|
||||
AdapterManager.cancel_download_songs(
|
||||
{*self._pending_downloads, *self._current_download_boxes.keys()}
|
||||
)
|
||||
self.emit("refresh-window", {}, False)
|
||||
|
||||
def _on_download_box_cancel_click(self, _, song_id: str):
|
||||
AdapterManager.cancel_download_songs([song_id])
|
||||
|
||||
def _on_retry_all_clicked(self, _):
|
||||
AdapterManager.batch_download_songs(
|
||||
self._failed_downloads, lambda _: None, lambda _: None,
|
||||
)
|
||||
|
||||
def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack:
|
||||
stack = Gtk.Stack()
|
||||
@@ -370,23 +482,26 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
)
|
||||
)
|
||||
current_downloads_label.get_style_context().add_class("menu-label")
|
||||
cancel_all_button = IconButton(
|
||||
self.cancel_all_button = IconButton(
|
||||
"process-stop-symbolic", "Cancel all downloads", sensitive=False
|
||||
)
|
||||
current_downloads_header.pack_end(cancel_all_button, False, False, 0)
|
||||
self.cancel_all_button.connect("clicked", self._on_cancel_all_clicked)
|
||||
current_downloads_header.pack_end(self.cancel_all_button, False, False, 0)
|
||||
vbox.add(current_downloads_header)
|
||||
|
||||
self.current_downloads_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, name="current-downloads-list"
|
||||
)
|
||||
self.current_downloads_placeholder = Gtk.Label(
|
||||
self._current_downloads_placeholder = Gtk.Label(
|
||||
label="<i>No current downloads</i>",
|
||||
use_markup=True,
|
||||
name="current-downloads-list-placeholder",
|
||||
)
|
||||
self.current_downloads_box.add(self.current_downloads_placeholder)
|
||||
self.current_downloads_box.add(self._current_downloads_placeholder)
|
||||
vbox.add(self.current_downloads_box)
|
||||
|
||||
vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
||||
|
||||
clear_cache = self._create_model_button("Clear Cache", menu_name="clear-cache")
|
||||
vbox.add(clear_cache)
|
||||
menu.add(vbox)
|
||||
@@ -895,25 +1010,50 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
|
||||
|
||||
class DownloadStatusBox(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
"cancel-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,),),
|
||||
"retry-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,),),
|
||||
}
|
||||
|
||||
def __init__(self, song_id: str):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
song = AdapterManager.get_song_details(song_id).result()
|
||||
self.song = AdapterManager.get_song_details(song_id).result()
|
||||
|
||||
self.song_label = Gtk.Label(label=song.title, halign=Gtk.Align.START)
|
||||
image = SpinnerImage(
|
||||
image_size=30, image_name="current-downloads-cover-art-image"
|
||||
)
|
||||
self.add(image)
|
||||
|
||||
artist = util.esc(self.song.artist.name if self.song.artist else None)
|
||||
label_text = util.dot_join(f"<b>{util.esc(self.song.title)}</b>", artist)
|
||||
self.song_label = Gtk.Label(
|
||||
label=label_text,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
max_width_chars=30,
|
||||
name="currently-downloading-song-title",
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
)
|
||||
self.pack_start(self.song_label, True, True, 5)
|
||||
|
||||
self.download_progress = Gtk.ProgressBar(show_text=True)
|
||||
self.download_progress.set_text(song.title)
|
||||
self.add(self.download_progress)
|
||||
|
||||
self.cancel_button = IconButton(
|
||||
"process-stop-symbolic", tooltip_text="Cancel download", relief=True
|
||||
"process-stop-symbolic", tooltip_text="Cancel download"
|
||||
)
|
||||
self.cancel_button.connect(
|
||||
"clicked", lambda *a: self.emit("cancel-clicked", self.song.id)
|
||||
)
|
||||
self.add(self.cancel_button)
|
||||
|
||||
def image_callback(f: Result):
|
||||
image.set_loading(False)
|
||||
image.set_from_file(f.result())
|
||||
|
||||
artwork_future = AdapterManager.get_cover_art_filename(self.song.cover_art)
|
||||
artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f))
|
||||
|
||||
def update_progress(self, progress_fraction: float):
|
||||
self.download_progress.set_fraction(progress_fraction)
|
||||
|
||||
def set_error(self, exception: Exception):
|
||||
print(exception)
|
||||
|
@@ -202,6 +202,7 @@ def show_song_popover(
|
||||
)
|
||||
|
||||
def on_remove_downloads_click(_: Any):
|
||||
AdapterManager.cancel_download_songs(song_ids)
|
||||
AdapterManager.batch_delete_cached_songs(
|
||||
song_ids, on_song_delete=on_download_state_change,
|
||||
)
|
||||
@@ -246,7 +247,12 @@ def show_song_popover(
|
||||
):
|
||||
download_song_button.set_sensitive(True)
|
||||
if any(
|
||||
status in (SongCacheStatus.CACHED, SongCacheStatus.PERMANENTLY_CACHED)
|
||||
status
|
||||
in (
|
||||
SongCacheStatus.CACHED,
|
||||
SongCacheStatus.PERMANENTLY_CACHED,
|
||||
SongCacheStatus.DOWNLOADING,
|
||||
)
|
||||
for status in song_cache_statuses
|
||||
):
|
||||
remove_download_button.set_sensitive(True)
|
||||
|
@@ -346,8 +346,8 @@ def test_invalidate_song_file(cache_adapter: FilesystemAdapter):
|
||||
cache_adapter.ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, "s1", MOCK_ALBUM_ART,
|
||||
)
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE))
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "2", (None, MOCK_SONG_FILE2))
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE, None))
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "2", (None, MOCK_SONG_FILE2, None))
|
||||
|
||||
cache_adapter.invalidate_data(KEYS.SONG_FILE, "1")
|
||||
cache_adapter.invalidate_data(KEYS.COVER_ART_FILE, "s1")
|
||||
@@ -366,10 +366,10 @@ def test_malformed_song_path(cache_adapter: FilesystemAdapter):
|
||||
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
|
||||
cache_adapter.ingest_new_data(KEYS.SONG, "2", MOCK_SUBSONIC_SONGS[0])
|
||||
cache_adapter.ingest_new_data(
|
||||
KEYS.SONG_FILE, "1", ("/malformed/path", MOCK_SONG_FILE)
|
||||
KEYS.SONG_FILE, "1", ("/malformed/path", MOCK_SONG_FILE, None)
|
||||
)
|
||||
cache_adapter.ingest_new_data(
|
||||
KEYS.SONG_FILE, "2", ("fine/path/song2.mp3", MOCK_SONG_FILE2)
|
||||
KEYS.SONG_FILE, "2", ("fine/path/song2.mp3", MOCK_SONG_FILE2, None)
|
||||
)
|
||||
|
||||
song_uri = cache_adapter.get_song_uri("1", "file")
|
||||
@@ -383,7 +383,7 @@ def test_get_cached_statuses(cache_adapter: FilesystemAdapter):
|
||||
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
|
||||
assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.NOT_CACHED}
|
||||
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE))
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE, None))
|
||||
assert cache_adapter.get_cached_statuses(["1"]) == {"1": SongCacheStatus.CACHED}
|
||||
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE_PERMANENT, "1", None)
|
||||
@@ -444,7 +444,7 @@ def test_delete_playlists(cache_adapter: FilesystemAdapter):
|
||||
|
||||
def test_delete_song_data(cache_adapter: FilesystemAdapter):
|
||||
cache_adapter.ingest_new_data(KEYS.SONG, "1", MOCK_SUBSONIC_SONGS[1])
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE))
|
||||
cache_adapter.ingest_new_data(KEYS.SONG_FILE, "1", (None, MOCK_SONG_FILE, None))
|
||||
cache_adapter.ingest_new_data(
|
||||
KEYS.COVER_ART_FILE, "s1", MOCK_ALBUM_ART,
|
||||
)
|
||||
|
@@ -171,6 +171,7 @@ def test_get_playlist_details(adapter: SubsonicAdapter):
|
||||
year=2016,
|
||||
_genre="Christian & Gospel",
|
||||
cover_art="318",
|
||||
size=8381640,
|
||||
duration=timedelta(seconds=238),
|
||||
path="/".join(
|
||||
(
|
||||
|
Reference in New Issue
Block a user