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,
SongCacheStatus,
)
from .manager import AdapterManager, Result, SearchResult
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
__all__ = (
"Adapter",
@@ -15,6 +15,7 @@ __all__ = (
"CacheMissError",
"CachingAdapter",
"ConfigParamDescriptor",
"DownloadProgress",
"Result",
"SearchResult",
"SongCacheStatus",

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import threading
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass
from datetime import timedelta
from enum import Enum
from functools import partial
from pathlib import Path
from time import sleep
@@ -58,9 +59,13 @@ if delay_str := os.environ.get("REQUEST_DELAY"):
REQUEST_DELAY = (float(delay_str), float(delay_str))
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
DOWNLOAD_BLOCK_DELAY: Optional[float] = None
if delay_str := os.environ.get("DOWNLOAD_BLOCK_DELAY"):
DOWNLOAD_BLOCK_DELAY = float(delay_str)
T = TypeVar("T")
@@ -159,6 +164,27 @@ class Result(Generic[T]):
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:
available_adapters: Set[Any] = {FilesystemAdapter, SubsonicAdapter}
current_download_ids: Set[str] = set()
@@ -171,6 +197,7 @@ class AdapterManager:
@dataclass
class _AdapterManagerInternal:
ground_truth_adapter: Adapter
on_song_download_progress: Callable[[Any, str, DownloadProgress], None]
caching_adapter: Optional[CachingAdapter] = None
concurrent_download_limit: int = 5
@@ -181,6 +208,9 @@ class AdapterManager:
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):
self.ground_truth_adapter.shutdown()
if self.caching_adapter:
@@ -231,8 +261,10 @@ class AdapterManager:
logging.info("AdapterManager shutdown complete")
@staticmethod
def reset(config: Any):
def reset(
config: Any,
on_song_download_progress: Callable[[Any, str, DownloadProgress], None],
):
from sublime.config import AppConfiguration
assert isinstance(config, AppConfiguration)
@@ -273,6 +305,7 @@ class AdapterManager:
AdapterManager._instance = AdapterManager._AdapterManagerInternal(
ground_truth_adapter,
on_song_download_progress,
caching_adapter=caching_adapter,
concurrent_download_limit=config.concurrent_download_limit,
)
@@ -345,7 +378,10 @@ class AdapterManager:
@staticmethod
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]:
"""
Create a function to download the given URI to a temporary file, and return the
@@ -368,6 +404,8 @@ class AdapterManager:
if before_download:
before_download()
expected_size_exists = expected_size is not None
# TODO (#122): figure out how to retry if the other request failed.
if resource_downloading:
logging.info(f"{uri} already being downloaded.")
@@ -393,16 +431,57 @@ class AdapterManager:
if NETWORK_ALWAYS_ERROR:
raise Exception("NETWORK_ALWAYS_ERROR enabled")
data = requests.get(uri)
# TODO (#122): make better
if not data:
raise Exception("Download failed!")
if "json" in data.headers.get("Content-Type", ""):
# Wait 10 seconds to connect to the server and start downloading.
# Then, for each of the blocks, give 5 seconds to download (which
# should be more than enough for 1 KiB).
request = requests.get(uri, stream=True, timeout=(10, 5))
if "json" in request.headers.get("Content-Type", ""):
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:
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:
# Always release the download set lock, even if there's an error.
with AdapterManager.download_set_lock:
@@ -872,11 +951,17 @@ class AdapterManager:
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(
@@ -885,16 +970,16 @@ class AdapterManager:
),
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, song_tmp_filename, None),
)
on_song_download_complete(song_id)
# Download the corresponding cover art.
song = AdapterManager.get_song_details(song_id).result()
AdapterManager.get_cover_art_filename(song.cover_art).result()
finally:
# Release the semaphore lock. This will allow the next song in the queue
@@ -905,6 +990,20 @@ class AdapterManager:
def do_batch_download_songs():
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:
if (
AdapterManager.is_shutting_down
@@ -926,6 +1025,13 @@ class AdapterManager:
nonlocal cancelled
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)
@staticmethod

View File

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

View File

@@ -31,7 +31,13 @@ except Exception:
)
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 .config import AppConfiguration
from .dbus import dbus_propagate, DBusManager
@@ -133,6 +139,7 @@ class SublimeMusicApp(Gtk.Application):
# Load the state for the server, if it exists.
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 self.app_config.server is None:
@@ -823,6 +830,7 @@ class SublimeMusicApp(Gtk.Application):
@dbus_propagate()
def on_volume_change(self, _, value: float):
assert self.player
self.app_config.state.volume = value
self.player.volume = self.app_config.state.volume
self.update_window()
@@ -855,6 +863,10 @@ class SublimeMusicApp(Gtk.Application):
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"):
self.exiting = True
if glib_notify_exists:
@@ -866,7 +878,8 @@ class SublimeMusicApp(Gtk.Application):
if self.app_config.server is None:
return
self.player.pause()
if self.player:
self.player.pause()
self.chromecast_player.shutdown()
self.mpv_player.shutdown()

View File

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

View File

@@ -2,6 +2,7 @@
#connected-to-label {
margin: 5px 15px;
font-size: 1.2em;
min-width: 200px;
}
#connected-status-row {
@@ -27,6 +28,10 @@
min-width: 250px;
}
#current-downloads-list-placeholder {
margin: 10px;
}
.menu-label {
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 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.ui import albums, artists, browse, player_controls, playlists, util
from sublime.ui.common import IconButton, IconMenuButton, SpinnerImage
@@ -29,6 +34,7 @@ class MainWindow(Gtk.ApplicationWindow):
}
_updating_settings: bool = False
_current_download_boxes: Dict[str, Gtk.Box] = {}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -184,6 +190,39 @@ class MainWindow(Gtk.ApplicationWindow):
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:
stack = Gtk.Stack()
for name, child in kwargs.items():
@@ -340,6 +379,12 @@ class MainWindow(Gtk.ApplicationWindow):
self.current_downloads_box = Gtk.Box(
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)
clear_cache = self._create_model_button("Clear Cache", menu_name="clear-cache")
@@ -847,3 +892,28 @@ class MainWindow(Gtk.ApplicationWindow):
return True
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,
cache_location=tmp_path.as_posix(),
)
AdapterManager.reset(config)
AdapterManager.reset(config, lambda *a: None)
yield
AdapterManager.shutdown()