Rudimentary progress bars

This commit is contained in:
Sumner Evans
2020-05-30 17:37:44 -06:00
parent effedbe0be
commit ca565cf719
11 changed files with 230 additions and 25 deletions

View File

@@ -6,7 +6,7 @@ from .adapter_base import (
ConfigParamDescriptor, ConfigParamDescriptor,
SongCacheStatus, SongCacheStatus,
) )
from .manager import AdapterManager, Result, SearchResult from .manager import AdapterManager, DownloadProgress, Result, SearchResult
__all__ = ( __all__ = (
"Adapter", "Adapter",
@@ -15,6 +15,7 @@ __all__ = (
"CacheMissError", "CacheMissError",
"CachingAdapter", "CachingAdapter",
"ConfigParamDescriptor", "ConfigParamDescriptor",
"DownloadProgress",
"Result", "Result",
"SearchResult", "SearchResult",
"SongCacheStatus", "SongCacheStatus",

View File

@@ -95,6 +95,7 @@ class Song(abc.ABC):
disc_number: Optional[int] disc_number: Optional[int]
year: Optional[int] year: Optional[int]
cover_art: Optional[str] cover_art: Optional[str]
size: Optional[int]
user_rating: Optional[int] user_rating: Optional[int]
starred: Optional[datetime] starred: Optional[datetime]

View File

@@ -614,7 +614,9 @@ class FilesystemAdapter(CachingAdapter):
if api_song.cover_art if api_song.cover_art
else None, else None,
"file": self._do_ingest_new_data( "file": self._do_ingest_new_data(
KEYS.SONG_FILE, api_song.id, data=(api_song.path, None) KEYS.SONG_FILE,
api_song.id,
data=(api_song.path, None, api_song.size),
) )
if api_song.path if api_song.path
else None, else None,
@@ -788,11 +790,14 @@ class FilesystemAdapter(CachingAdapter):
# Special handling for Song # Special handling for Song
if data_key == KEYS.SONG_FILE and data: if data_key == KEYS.SONG_FILE and data:
path, buffer_filename = data path, buffer_filename, size = data
if path: if path:
cache_info.path = path cache_info.path = path
if size:
cache_info.size = size
if buffer_filename: if buffer_filename:
cache_info.file_hash = compute_file_hash(buffer_filename) cache_info.file_hash = compute_file_hash(buffer_filename)

View File

@@ -43,6 +43,7 @@ class CacheInfo(BaseModel):
# Used for cached files. # Used for cached files.
file_id = TextField(null=True) file_id = TextField(null=True)
file_hash = TextField(null=True) file_hash = TextField(null=True)
size = IntegerField(null=True)
path = TextField(null=True) path = TextField(null=True)
cache_permanently = BooleanField(null=True) cache_permanently = BooleanField(null=True)
@@ -165,6 +166,13 @@ class Song(BaseModel):
# figure out how to deal with different transcodings, etc. # figure out how to deal with different transcodings, etc.
file = ForeignKeyField(CacheInfo, null=True) file = ForeignKeyField(CacheInfo, null=True)
@property
def size(self) -> Optional[int]:
try:
return self.file.size
except Exception:
return None
@property @property
def path(self) -> Optional[str]: def path(self) -> Optional[str]:
try: try:

View File

@@ -8,6 +8,7 @@ import threading
from concurrent.futures import Future, ThreadPoolExecutor from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from enum import Enum
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from time import sleep from time import sleep
@@ -58,9 +59,13 @@ if delay_str := os.environ.get("REQUEST_DELAY"):
REQUEST_DELAY = (float(delay_str), float(delay_str)) REQUEST_DELAY = (float(delay_str), float(delay_str))
NETWORK_ALWAYS_ERROR: bool = False NETWORK_ALWAYS_ERROR: bool = False
if always_error := os.environ.get("NETWORK_ALWAYS_ERROR"): if os.environ.get("NETWORK_ALWAYS_ERROR"):
NETWORK_ALWAYS_ERROR = True NETWORK_ALWAYS_ERROR = True
DOWNLOAD_BLOCK_DELAY: Optional[float] = None
if delay_str := os.environ.get("DOWNLOAD_BLOCK_DELAY"):
DOWNLOAD_BLOCK_DELAY = float(delay_str)
T = TypeVar("T") T = TypeVar("T")
@@ -159,6 +164,27 @@ class Result(Generic[T]):
return self._data is not None return self._data is not None
@dataclass
class DownloadProgress:
class Type(Enum):
QUEUED = 0
PROGRESS = 1
DONE = 2
CANCELLED = 3
ERROR = 4
type: Type
total_bytes: Optional[int] = None
current_bytes: Optional[int] = None
exception: Optional[Exception] = None
@property
def progress_fraction(self) -> Optional[float]:
if not self.current_bytes or not self.total_bytes:
return None
return self.current_bytes / self.total_bytes
class AdapterManager: class AdapterManager:
available_adapters: Set[Any] = {FilesystemAdapter, SubsonicAdapter} available_adapters: Set[Any] = {FilesystemAdapter, SubsonicAdapter}
current_download_ids: Set[str] = set() current_download_ids: Set[str] = set()
@@ -171,6 +197,7 @@ class AdapterManager:
@dataclass @dataclass
class _AdapterManagerInternal: class _AdapterManagerInternal:
ground_truth_adapter: Adapter ground_truth_adapter: Adapter
on_song_download_progress: Callable[[Any, str, DownloadProgress], None]
caching_adapter: Optional[CachingAdapter] = None caching_adapter: Optional[CachingAdapter] = None
concurrent_download_limit: int = 5 concurrent_download_limit: int = 5
@@ -181,6 +208,9 @@ class AdapterManager:
self.concurrent_download_limit self.concurrent_download_limit
) )
def song_download_progress(self, file_id: str, progress: DownloadProgress):
self.on_song_download_progress(file_id, progress)
def shutdown(self): def shutdown(self):
self.ground_truth_adapter.shutdown() self.ground_truth_adapter.shutdown()
if self.caching_adapter: if self.caching_adapter:
@@ -231,8 +261,10 @@ class AdapterManager:
logging.info("AdapterManager shutdown complete") logging.info("AdapterManager shutdown complete")
@staticmethod @staticmethod
def reset(config: Any): def reset(
config: Any,
on_song_download_progress: Callable[[Any, str, DownloadProgress], None],
):
from sublime.config import AppConfiguration from sublime.config import AppConfiguration
assert isinstance(config, AppConfiguration) assert isinstance(config, AppConfiguration)
@@ -273,6 +305,7 @@ class AdapterManager:
AdapterManager._instance = AdapterManager._AdapterManagerInternal( AdapterManager._instance = AdapterManager._AdapterManagerInternal(
ground_truth_adapter, ground_truth_adapter,
on_song_download_progress,
caching_adapter=caching_adapter, caching_adapter=caching_adapter,
concurrent_download_limit=config.concurrent_download_limit, concurrent_download_limit=config.concurrent_download_limit,
) )
@@ -345,7 +378,10 @@ class AdapterManager:
@staticmethod @staticmethod
def _create_download_fn( def _create_download_fn(
uri: str, id: str, before_download: Callable[[], None] = None, uri: str,
id: str,
before_download: Callable[[], None] = None,
expected_size: int = None,
) -> Callable[[], str]: ) -> Callable[[], str]:
""" """
Create a function to download the given URI to a temporary file, and return the Create a function to download the given URI to a temporary file, and return the
@@ -368,6 +404,8 @@ class AdapterManager:
if before_download: if before_download:
before_download() before_download()
expected_size_exists = expected_size is not None
# TODO (#122): figure out how to retry if the other request failed. # TODO (#122): figure out how to retry if the other request failed.
if resource_downloading: if resource_downloading:
logging.info(f"{uri} already being downloaded.") logging.info(f"{uri} already being downloaded.")
@@ -393,16 +431,57 @@ class AdapterManager:
if NETWORK_ALWAYS_ERROR: if NETWORK_ALWAYS_ERROR:
raise Exception("NETWORK_ALWAYS_ERROR enabled") raise Exception("NETWORK_ALWAYS_ERROR enabled")
data = requests.get(uri) # Wait 10 seconds to connect to the server and start downloading.
# Then, for each of the blocks, give 5 seconds to download (which
# TODO (#122): make better # should be more than enough for 1 KiB).
if not data: request = requests.get(uri, stream=True, timeout=(10, 5))
raise Exception("Download failed!") if "json" in request.headers.get("Content-Type", ""):
if "json" in data.headers.get("Content-Type", ""):
raise Exception("Didn't expect JSON!") raise Exception("Didn't expect JSON!")
total_size = int(request.headers.get("Content-Length", 0))
if expected_size_exists:
if total_size != expected_size:
raise Exception(
f"Download content size ({total_size})is not the "
f"expected size ({expected_size})."
)
block_size = 1024 # 1 KiB
total_consumed = 0
with open(download_tmp_filename, "wb+") as f: with open(download_tmp_filename, "wb+") as f:
f.write(data.content) for i, data in enumerate(request.iter_content(block_size)):
total_consumed += len(data)
f.write(data)
if i % 5 == 0:
# Only delay (if configured) and update the progress UI
# every 5 KiB.
if DOWNLOAD_BLOCK_DELAY is not None:
sleep(DOWNLOAD_BLOCK_DELAY)
if expected_size_exists:
AdapterManager._instance.song_download_progress(
id,
DownloadProgress(
DownloadProgress.Type.PROGRESS,
total_bytes=total_size,
current_bytes=total_consumed,
),
)
# Everything succeeded.
if expected_size_exists:
AdapterManager._instance.song_download_progress(
id, DownloadProgress(DownloadProgress.Type.DONE),
)
except Exception as e:
if expected_size_exists:
# Something failed. Post an error.
AdapterManager._instance.song_download_progress(
id,
DownloadProgress(DownloadProgress.Type.ERROR, exception=e),
)
finally: finally:
# Always release the download set lock, even if there's an error. # Always release the download set lock, even if there's an error.
with AdapterManager.download_set_lock: with AdapterManager.download_set_lock:
@@ -872,11 +951,17 @@ class AdapterManager:
AdapterManager._instance.caching_adapter.get_song_uri( AdapterManager._instance.caching_adapter.get_song_uri(
song_id, "file" song_id, "file"
) )
AdapterManager._instance.song_download_progress(
song_id, DownloadProgress(DownloadProgress.Type.DONE),
)
song = AdapterManager.get_song_details(song_id).result()
except CacheMissError: except CacheMissError:
# The song is not already cached. # The song is not already cached.
if before_download: if before_download:
before_download(song_id) before_download(song_id)
song = AdapterManager.get_song_details(song_id).result()
# Download the song. # Download the song.
# TODO (#64) handle download errors? # TODO (#64) handle download errors?
song_tmp_filename = AdapterManager._create_download_fn( song_tmp_filename = AdapterManager._create_download_fn(
@@ -885,16 +970,16 @@ class AdapterManager:
), ),
song_id, song_id,
lambda: before_download(song_id), lambda: before_download(song_id),
expected_size=song.size,
)() )()
AdapterManager._instance.caching_adapter.ingest_new_data( AdapterManager._instance.caching_adapter.ingest_new_data(
CachingAdapter.CachedDataKey.SONG_FILE, CachingAdapter.CachedDataKey.SONG_FILE,
song_id, song_id,
(None, song_tmp_filename), (None, song_tmp_filename, None),
) )
on_song_download_complete(song_id) on_song_download_complete(song_id)
# Download the corresponding cover art. # Download the corresponding cover art.
song = AdapterManager.get_song_details(song_id).result()
AdapterManager.get_cover_art_filename(song.cover_art).result() AdapterManager.get_cover_art_filename(song.cover_art).result()
finally: finally:
# Release the semaphore lock. This will allow the next song in the queue # Release the semaphore lock. This will allow the next song in the queue
@@ -905,6 +990,20 @@ class AdapterManager:
def do_batch_download_songs(): def do_batch_download_songs():
sleep(delay) sleep(delay)
if (
AdapterManager.is_shutting_down
or AdapterManager._offline_mode
or cancelled
):
return
# Alert the UI that the downloads are queue.
for song_id in song_ids:
# Everything succeeded.
AdapterManager._instance.song_download_progress(
song_id, DownloadProgress(DownloadProgress.Type.QUEUED),
)
for song_id in song_ids: for song_id in song_ids:
if ( if (
AdapterManager.is_shutting_down AdapterManager.is_shutting_down
@@ -926,6 +1025,13 @@ class AdapterManager:
nonlocal cancelled nonlocal cancelled
cancelled = True cancelled = True
# 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) return Result(do_batch_download_songs, is_download=True, on_cancel=on_cancel)
@staticmethod @staticmethod

View File

@@ -188,6 +188,7 @@ class Song(SublimeAPI.Song, DataClassJsonMixin):
track: Optional[int] = None track: Optional[int] = None
disc_number: Optional[int] = None disc_number: Optional[int] = None
year: Optional[int] = None year: Optional[int] = None
size: Optional[int] = None
cover_art: Optional[str] = None cover_art: Optional[str] = None
user_rating: Optional[int] = None user_rating: Optional[int] = None
starred: Optional[datetime] = None starred: Optional[datetime] = None

View File

@@ -31,7 +31,13 @@ except Exception:
) )
glib_notify_exists = False glib_notify_exists = False
from .adapters import AdapterManager, AlbumSearchQuery, Result, SongCacheStatus from .adapters import (
AdapterManager,
AlbumSearchQuery,
DownloadProgress,
Result,
SongCacheStatus,
)
from .adapters.api_objects import Playlist, PlayQueue, Song from .adapters.api_objects import Playlist, PlayQueue, Song
from .config import AppConfiguration from .config import AppConfiguration
from .dbus import dbus_propagate, DBusManager from .dbus import dbus_propagate, DBusManager
@@ -133,6 +139,7 @@ class SublimeMusicApp(Gtk.Application):
# Load the state for the server, if it exists. # Load the state for the server, if it exists.
self.app_config.load_state() self.app_config.load_state()
AdapterManager.reset(self.app_config, self.on_song_download_progress)
# If there is no current server, show the dialog to select a server. # If there is no current server, show the dialog to select a server.
if self.app_config.server is None: if self.app_config.server is None:
@@ -823,6 +830,7 @@ class SublimeMusicApp(Gtk.Application):
@dbus_propagate() @dbus_propagate()
def on_volume_change(self, _, value: float): def on_volume_change(self, _, value: float):
assert self.player
self.app_config.state.volume = value self.app_config.state.volume = value
self.player.volume = self.app_config.state.volume self.player.volume = self.app_config.state.volume
self.update_window() self.update_window()
@@ -855,6 +863,10 @@ class SublimeMusicApp(Gtk.Application):
return False return False
def on_song_download_progress(self, song_id: str, progress: DownloadProgress):
assert self.window
self.window.update_song_download_progress(song_id, progress)
def on_app_shutdown(self, app: "SublimeMusicApp"): def on_app_shutdown(self, app: "SublimeMusicApp"):
self.exiting = True self.exiting = True
if glib_notify_exists: if glib_notify_exists:
@@ -866,7 +878,8 @@ class SublimeMusicApp(Gtk.Application):
if self.app_config.server is None: if self.app_config.server is None:
return return
self.player.pause() if self.player:
self.player.pause()
self.chromecast_player.shutdown() self.chromecast_player.shutdown()
self.mpv_player.shutdown() self.mpv_player.shutdown()

View File

@@ -172,11 +172,6 @@ class AppConfiguration:
# Just ignore any errors, it is only UI state. # Just ignore any errors, it is only UI state.
self._state = UIState() self._state = UIState()
# Do the import in the function to avoid circular imports.
from sublime.adapters import AdapterManager
AdapterManager.reset(self)
@property @property
def state_file_location(self) -> Optional[Path]: def state_file_location(self) -> Optional[Path]:
if self.server is None: if self.server is None:

View File

@@ -2,6 +2,7 @@
#connected-to-label { #connected-to-label {
margin: 5px 15px; margin: 5px 15px;
font-size: 1.2em; font-size: 1.2em;
min-width: 200px;
} }
#connected-status-row { #connected-status-row {
@@ -27,6 +28,10 @@
min-width: 250px; min-width: 250px;
} }
#current-downloads-list-placeholder {
margin: 10px;
}
.menu-label { .menu-label {
margin-right: 15px; margin-right: 15px;
} }

View File

@@ -3,7 +3,12 @@ from typing import Any, Callable, Dict, Optional, Set, Tuple
from gi.repository import Gdk, GLib, GObject, Gtk, Pango from gi.repository import Gdk, GLib, GObject, Gtk, Pango
from sublime.adapters import AdapterManager, api_objects as API, Result from sublime.adapters import (
AdapterManager,
api_objects as API,
DownloadProgress,
Result,
)
from sublime.config import AppConfiguration, ReplayGainType from sublime.config import AppConfiguration, ReplayGainType
from sublime.ui import albums, artists, browse, player_controls, playlists, util from sublime.ui import albums, artists, browse, player_controls, playlists, util
from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage
@@ -29,6 +34,7 @@ class MainWindow(Gtk.ApplicationWindow):
} }
_updating_settings: bool = False _updating_settings: bool = False
_current_download_boxes: Dict[str, Gtk.Box] = {}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -184,6 +190,39 @@ class MainWindow(Gtk.ApplicationWindow):
self.player_controls.update(app_config, force=force) self.player_controls.update(app_config, force=force)
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])
elif progress.type in (
DownloadProgress.Type.DONE,
DownloadProgress.Type.CANCELLED,
):
# Remove and delet the box for the download.
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]
elif progress.type == DownloadProgress.Type.ERROR:
GLib.idle_add(
self._current_download_boxes[song_id].set_error, progress.exception
)
elif progress.type == DownloadProgress.Type.PROGRESS:
GLib.idle_add(
self._current_download_boxes[song_id].update_progress,
progress.progress_fraction,
)
if len(self._current_download_boxes) == 0:
self.current_downloads_placeholder.show()
else:
self.current_downloads_placeholder.hide()
def _create_download_status_box(self, song_id: str) -> Gtk.Box:
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
return status_box
def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack: def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack:
stack = Gtk.Stack() stack = Gtk.Stack()
for name, child in kwargs.items(): for name, child in kwargs.items():
@@ -340,6 +379,12 @@ class MainWindow(Gtk.ApplicationWindow):
self.current_downloads_box = Gtk.Box( self.current_downloads_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, name="current-downloads-list" orientation=Gtk.Orientation.VERTICAL, name="current-downloads-list"
) )
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)
vbox.add(self.current_downloads_box) vbox.add(self.current_downloads_box)
clear_cache = self._create_model_button("Clear Cache", menu_name="clear-cache") clear_cache = self._create_model_button("Clear Cache", menu_name="clear-cache")
@@ -847,3 +892,28 @@ class MainWindow(Gtk.ApplicationWindow):
return True return True
return False return False
class DownloadStatusBox(Gtk.Box):
def __init__(self, song_id: str):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
song = AdapterManager.get_song_details(song_id).result()
self.song_label = Gtk.Label(label=song.title, 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
)
self.add(self.cancel_button)
def update_progress(self, progress_fraction: float):
self.download_progress.set_fraction(progress_fraction)
def set_error(self, exception: Exception):
print(exception)

View File

@@ -19,7 +19,7 @@ def adapter_manager(tmp_path: Path):
current_server_index=0, current_server_index=0,
cache_location=tmp_path.as_posix(), cache_location=tmp_path.as_posix(),
) )
AdapterManager.reset(config) AdapterManager.reset(config, lambda *a: None)
yield yield
AdapterManager.shutdown() AdapterManager.shutdown()