Merge branch '64-current-downloads-dropdown'

This commit is contained in:
Sumner Evans
2020-06-03 23:03:33 -06:00
13 changed files with 447 additions and 167 deletions

View File

@@ -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

View File

@@ -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
------------

View File

@@ -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:

View File

@@ -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=[

View File

@@ -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>

View File

@@ -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
]

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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(
(